Initial commit

This commit is contained in:
LeoVasanko committed 2026-10-07 03:07:34 +00:00
commit 03a7758fa6
24 files changed
+3408

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
*.lock
.*
!.gitignore
# demo runtime state: live credentials, never commit
demo/psk
demo/*.keys
# python build artifacts
__pycache__/
*.py[cod]
build/
dist/
*.egg-info/
+50
View File
@@ -0,0 +1,50 @@
"""Demo client: python demo/client.py
Connects to ws://127.0.0.1:8765, initiates the handshake, prompts for
the out-of-band digest comparison, then relays stdin lines encrypted.
Empty line quits.
"""
import asyncio
from common import key_store, save_store, setup_logging
from websockets import connect
from websockets.exceptions import ConnectionClosed
from quantumpipe import ProtocolError, SecSocket, Verification, sas
log = setup_logging("client")
async def confirm_digest(v: Verification) -> bool | None:
print(f"\n*** compare these emojis with the server:\n{sas.format(v.sas)}")
answer = await asyncio.to_thread(input, "*** match? [y]es / [n]o / [s]kip: ")
return {"y": True, "n": False}.get(answer.strip().lower()[:1] or "s")
async def main() -> None:
store = key_store("client")
async with connect("ws://127.0.0.1:8765") as ws:
sock = SecSocket(ws, store, "peer", handlers={Verification: confirm_digest})
try:
await sock.connect()
except (ProtocolError, ConnectionClosed) as e:
log.error("handshake failed: %s", e)
return
finally:
save_store("client", store) # rotation, flag, burn, delete
log.info(
"handshake done (%s, verified=%s); type lines, empty line quits",
sock.session.state,
sock.session.verified,
)
while line := await asyncio.to_thread(input, "> "):
await sock.send(line)
print("server:", await sock.receive())
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, EOFError):
pass
+47
View File
@@ -0,0 +1,47 @@
"""Shared helpers for the demo scripts: logging + the key store file.
Credential story (a real deployment would do this properly):
- demo/psk holds the out-of-band registration secret, created by
whichever script runs first (both scripts must see the same file).
- demo/<role>.keys holds the key store (JSON: peer id -> hex
credential). Initially it maps "peer" to the registration secret;
the handshake protocol itself replaces entries as credentials
rotate, get flagged verified, or are burned. The scripts persist
the store after every connection attempt, success or failure.
"""
import json
import logging
from pathlib import Path
HERE = Path(__file__).parent
def setup_logging(role: str) -> logging.Logger:
logging.basicConfig(
level=logging.DEBUG,
format=f"%(asctime)s {role} %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
return logging.getLogger(role)
def psk() -> bytes:
f = HERE / "psk"
if not f.exists():
from quantumpipe import generate_registration_secret
f.write_bytes(generate_registration_secret())
return f.read_bytes()
def key_store(role: str) -> dict:
f = HERE / f"{role}.keys"
if f.exists():
return {k: bytes.fromhex(v) for k, v in json.loads(f.read_text()).items()}
return {"peer": psk()}
def save_store(role: str, store: dict) -> None:
f = HERE / f"{role}.keys"
f.write_text(json.dumps({k: v.hex() for k, v in store.items()}, indent=1) + "\n")
+65
View File
@@ -0,0 +1,65 @@
"""Demo server: python demo/server.py
Accepts WebSocket clients on ws://127.0.0.1:8765, runs the handshake as
responder (identifying each caller by trying its key store against
msg1), prints the verification digest for comparison, then echoes
messages back. Ctrl-C to stop.
"""
import asyncio
from common import key_store, psk, save_store, setup_logging
from websockets import serve
from websockets.exceptions import ConnectionClosed
from quantumpipe import ProtocolError, SecSocket, Verification, sas
log = setup_logging("server")
psk() # eager: create demo/psk before we listen (avoids a creation race)
async def show_digest(v: Verification) -> None:
# returning None answers with the app default: proceed unverified --
# the digest stays exposed (sock.session.verification) for an optional
# comparison, confirmed later via sock.session.confirm; answering False
# instead would delete the credential
log.info("compare these emojis with the client:\n%s", sas.format(v.sas))
async def handle(ws) -> None:
# per connection: re-read the store; the handshake mutates it in
# place (rotation, flag, burn, delete) and we persist after
store = key_store("server")
sock = SecSocket(ws, store, handlers={Verification: show_digest})
try:
await sock.accept()
except (ProtocolError, ConnectionClosed) as e:
log.info("handshake failed: %s", e)
return
finally:
save_store("server", store)
log.info(
"handshake done (%s, verified=%s), echoing",
sock.session.state,
sock.session.verified,
)
try:
while True:
msg = await sock.receive()
log.info("received %r", msg)
await sock.send(f"echo: {msg}")
except ConnectionClosed:
log.info("client disconnected")
async def main() -> None:
async with serve(handle, "127.0.0.1", 8765):
log.info("listening on ws://127.0.0.1:8765")
await asyncio.Future() # serve forever
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+141
View File
@@ -0,0 +1,141 @@
# Design notes
## Architecture
- `chab3.py` -- ChaCha20-Blake3 AEAD boxes, counter-nonce channels, CSPRNG.
- `handshake.py` -- the key-exchange cryptography. A `Registration` or
`Resumption` object (two fully separate classes; the shared parts are
free functions) holds one exchange's per-connection state -- ephemeral
root, commitment, exchange secrets, the ad chain (a tiny `_Chain`
class) -- and is disposed by the caller once the exchange completes;
its secret fields are a candidate for SecureBuffer backing.
- `sansio.py` -- the protocol state machine (`Session`), the key store
and its policies, traffic framing.
- `ws.py` -- `SecSocket`, a thin asyncio WebSocket pump over a Session.
- `exceptions.py` -- `ProtocolError`, in its own module so wrappers and
applications can import it without the sans-io machinery.
## Driving a session
`session()` with no message initiates ("alice"), receiving msg1 first
responds ("bob"); results are bytes (wire messages to send) or signal
objects. A `Verification` carries the out-of-band comparison digest --
fill `.answer`, pass it back. Registration gates its tail on the
answers: the responder is asked after msg3 (his answer releases msg4),
the initiator after msg4 (her answer releases msg5 and finishes her
side); a False answer deletes the credential and the held message is
never sent. Resumptions never prompt: the stored sas rides on
`Established` and the parties may compare it whenever they wish, then
`Session.confirm()` flags the credential verified or deletes it. Once
established, traffic goes through the same dispatch:
`session(Message)` encrypts a frame, `session(frame)` decrypts one back.
Keeping everything on the dispatch -- rather than separate encrypt/decrypt
calls -- is what allows in-band protocol messages and their own result
patterns later (re-keying, post-close semantics).
Which handshake a session runs is determined by its credential: 32 bytes
(bare shared) -> registration, credential-sized -> resumption. The
initiator is constructed with its key id; the responder without one,
identifying the caller by trying msg1 against every credential in the
store -- an AEAD open per candidate, cheap even for many.
## The key store
A mutable mapping (a plain dict works) of one credential per peer under
an application-chosen id: either the bare 32-byte registration secret
(given to both parties out of band; it IS the initial `shared`,
unhashed; the kdf info strings provide all domain separation needed) or
the composite written by the first successful registration:
idroot(32) | shared'(32) | peer_public_key(1216) | sas(4) | verified(1)
The rotation to shared' is derived when the exchange completes but only
held in memory: the store swap waits for the key change -- the
initiator swaps at her comparison answer, the responder when msg5
completes. Until then the registration secret keeps serving new
connections, and a drop before the swap loses nothing but the session.
Each swap is atomic: True and None swap the composite in (flagged
verified / unverified), False deletes the credential instead. An
unverified credential is never prompted again.
The swap is also the arbitration point for parallel registrations on
the same registration key: only the first swap wins; a later one finds
the key gone and fails with "registration key already spent" instead of
overwriting. Re-provisioning after a deletion needs a fresh
registration secret.
Every store write is a compare-and-swap against the credential the
session loaded: with several connections in flight on one store, only
the first to reach the atomic key change wins, and a loser's later
burn/delete/flag-flip never touches a slot that changed underneath it;
the verified-flag flip tolerates a parallel confirmation of the same
credential (the flag byte is excluded from the comparison). The store is
synchronously accessed, so these checks are atomic by construction, and
it is only ever mutated in place; persisting it (after results or
errors) is the application's business.
### Burns
Invalid data inside an otherwise authentic box (bad kdf tags, a bad
commitment reveal, low-order points, malformed bodies) burns the
credential on the spot: the sender demonstrably knew the keys and
misbehaved. AEAD failures and early disconnects instead change
nothing -- they prove no key knowledge -- and the same registration
secret can simply be tried again. A burn only touches the slot if it
still holds the session's own credential: a parallel connection may have
won the slot in the meantime.
## Frame kinds
One kind byte heads every traffic box's plaintext. The kind is opaque to
the state machine, with a single rule: kind 0 is reserved for in-band
protocol messages (none implemented yet -- key rotation and the like
will ride there, produced and consumed inside the dispatch); all other
values are user types, delivered untouched as `Message.kind`.
The str/bytes typing is the WebSocket wrapper's business (`ws.py`):
`send()` picks the kind from the object (str -> TEXT, bytes -> BINARY, a
Message passes through), `receive()` maps it back. Applications wanting
their own kinds (protobuf etc.) send/receive `Message` directly; a
future third layer may take this mapping over from the wrapper, so other
transports don't reimplement it.
## Errors
Everything attributable to the peer, the wire, or protocol-level misuse
surfaces as `ProtocolError`; the application action is always the
same -- log and disconnect. The conversion happens at one point, the
Session dispatch; the burn policy is already decided at the internal
raise sites (handshake.py raises `_Burn` for invalid data inside a valid
box; chab3's ValueError means an unauthentic box) before anything
escapes. Genuine bugs (TypeError, AttributeError) propagate untouched;
chab3 raises ValueError for bad wire data and TypeError only for program
bugs (too-small output buffers). A corrupt LOCAL store entry raises
KeyError/struct.error from the constructor -- the caller's own data
problem.
## The WebSocket wrapper
`SecSocket` duck-types the `websockets` library connection (send/recv)
and FastAPI/Starlette's WebSocket (accept/send_bytes/receive_bytes). The
socket knows no protocol state: bytes results are sent, other results go
to a handler registered for their type, and a Verification result is
passed back to the session once its handler ran (the handler sets
.answer, or returns the value). The Established result ends
accept()/connect().
The out-of-band verification is just one registered handler:
async def verify(v: Verification) -> bool | None
True confirms, False rejects (the credential is deleted, the handshake
fails), None skips (the default when no handler is registered).
accept()/connect() return only after the answer: no data flows before
it. Resumptions never prompt; the stored digest stays exposed as
sock.session.verification for an optional later comparison
(sock.session.confirm).
sock = SecSocket(ws, store, "peer-1", ad=b"myapp", handlers={Verification: verify})
await sock.connect() # client side; accept() on the server side
await sock.send(b"data")
msg = await sock.receive() # str | bytes | Message
+163
View File
@@ -0,0 +1,163 @@
# The quantumpipe protocol
Hybrid ML-KEM-768 + X25519 mutually authenticated key exchange on top of
a symmetric shared key, ChaCha20-Blake3 AEAD boxes (chab3) throughout.
All key material crossing the public API is plain `bytes`; cryptography
key objects are built and discarded internally.
## The KDF
`kdf(info, *items, length=n)` is a length-framed SHA-512 KDF (no
HKDF/HMAC):
kdf(info, *items, length=n)
= SHA512( len(items+1) || len(info) || info
|| len(items[0]) || items[0] || ... )[:n]
All integers are 8-byte little-endian; every item is individually
length-framed, so the input encoding is unambiguous and each item's
contribution is independently domain-separated. `str` arguments are
UTF-8 encoded before their length is counted. By convention the first
argument is always the info text. `length` is capped at 64 (the SHA-512
output size).
Info strings are short functional labels. Wire boxes are namespaced per
flow and message -- reg/msg1, reg/msg2, reg/msg3 (plus the inner box
reg/msg2/inner) for registration, res/msg1..3 for resumption -- so the
two exchanges are cryptographically disjoint. Derivations that do not
box a message carry no msg prefix and are named purely by function;
where both flows perform the same derivation through the same helper
with the same input format, they share one label (the traffic keys
a2b/b2a, the idroot subkeys idroot/mlkem and idroot/x25519, cred/id).
## The hybrid KEM
ML-KEM-768 (pk 1184 / sk-seed 64 / ct 1088) concatenated with X25519
(32). A private key is a single 32-byte idroot secret: each scheme's key
is derived from it with one domain-separated kdf call, so the two
subkeys are independent PRF outputs and the idroot is the only thing
held.
ML-KEM uses FIPS 203 implicit rejection: decapsulation never fails, and
a wrong key yields garbage caught by the kdf tags. X25519 has no such
property, so low-order (non-contributory) public keys and capsules are
rejected explicitly.
An encapsulation's output is a hybrid CAPSULE: an ML-KEM ciphertext ||
a fresh X25519 ephemeral public key (the X25519 "encapsulation" is a
DH, and its public half rides inside the capsule). The only ephemeral
public key shipped separately is alice's e_alice in msg1 -- bob must
receive it before he can encapsulate to it; every other leg is an
encapsulation and brings its own ephemeral along.
## Transcript chaining
No running hash, and box keys derive only from key-exchange secrets.
Instead each box's associated data is the tag of the previous message,
so the AEAD itself enforces an untampered transcript before any
plaintext or key is released. The chain starts from an application ad
given to the constructor (session context; both sides must pass the same
value), which is bound into msg1.
## Registration
First connection. Every leg is the same hybrid KEM operation: ee_cap
gives forward secrecy, es_cap and se_cap prove the two statics. The
statics pass late and hidden: s_bob inside msg2's inner box, s_alice
inside msg3's. Each box key lags one secret behind the proof inside it,
so invalid data inside an authentic box burns the credential, while an
AEAD failure proves no key knowledge and changes nothing:
msg1 A->B: box(e_alice | c) k1 = kdf(reg/msg1, shared)
msg2 B->A: box(ee_cap | box'(s_bob)) k2 = kdf(reg/msg2, shared)
k' = kdf(reg/msg2/inner, ee)
msg3 A->B: box(es_cap | box'(s_alice | r)) k3 = kdf(reg/msg3, shared, ee)
k' = kdf(reg/msg3/inner, es)
msg4 B->A: box(se_cap | tb) k4 = kdf(reg/msg4, shared, ee)
msg5 A->B: box(ta) k5 = kdf(reg/msg5, shared, ee, es)
c = kdf(commit, e_alice, r) r = random reveal, chosen with e_alice
ee = Encap(e_alice) forward secrecy
es = Encap(s_bob) challenges bob's static
se = Encap(s_alice) challenges alice's static
root = kdf(reg/root, shared, ee, es, se)
tb = kdf(reg/confirm-b, root) proves bob decapsulated es_cap
ta = kdf(reg/confirm-a, root) proves alice decapsulated se_cap
sas = kdf(sas, shared, ee, r, length=4) -- compared out of band
shared' = kdf(rotate, root), send/recv = kdf(a2b | b2a, root)
e_alice, s_alice, s_bob are hybrid public keys; the only ephemeral
public shipped is alice's e_alice (the responder needs none of his
own: every leg is an encapsulation, which already carries a fresh
ephemeral inside the capsule). The inner boxes key only on the secret
they actually protect: the outer box already binds shared and ee, so
there is no use in repeating them in the inner key.
The exchange's tail is gated on the out-of-band comparison: msg4 is
released only after the responder's answer, msg5 only after the
initiator's, and a rejection ends the exchange right there. The key
change happens at the very end for each side -- the initiator at her
answer, the responder when msg5 completes (see design.md).
### The comparison (sas)
The sas binds three things: the registration secret, the
per-connection ee, and the initiator's hidden reveal -- nothing later.
That is deliberate: it can be computed while the exchange is still
running, so a comparison dialog may be shown early while only the
final messages wait on the answers.
The reveal is committed in msg1 (c above) and disclosed only in msg3,
checked against c. Without the commitment a MITM who knows the
registration secret and runs independent legs with both parties would
learn bob's digest first and grind his own material offline until the
short digests match. With it, bob's digest is pinned to the commitment
he received before the reveal could be known: it can neither be
predicted nor steered.
And ee must be in the digest: it is contributed by the responder's
encapsulation after the initiator has committed. Without it, a MITM
could instead complete a leg with alice, steal her reveal, and
re-commit it toward bob in a fresh leg -- the digests would match
although there is no shared session. With ee the two legs' digests
differ and cannot be aligned: matching them means guessing ee before
it exists, and every such attempt costs a fresh online connection.
## Resumption
Statics PROVEN, never on the wire; all legs hybrid:
msg1 A->B: Box_k1(e_alice | sb_cap) k1 = kdf(res/msg1, shared)
msg2 B->A: Box_k2(sa_cap | ee_cap | tb) k2 = kdf(res/msg2, shared, sb)
msg3 A->B: Box_k3(ta) k3 = kdf(res/msg3, shared, sb, sa, ee)
tb = kdf(res/tag-b, sb, sa, ee) ta = kdf(res/tag-a, sb, sa, ee)
key = kdf(res/session-key, shared, sb, sa, ee)
-> send/recv = kdf(a2b | b2a, key)
sb_cap = Encap(s_bob) authenticates bob (to alice, via tb)
sa_cap = Encap(s_alice) authenticates alice (to bob, via ta)
ee_cap = Encap(e_alice) forward secrecy
Bob ships no ephemeral of his own here: no leg references it (ee is an
encapsulation to alice's ephemeral, whose fresh X25519 public half
already rides inside ee_cap). With the statics pre-shared, challenges
and answers fit into msg1..3, so possession is proven by the per-leg
tags tb/ta directly, without registration's extra tail messages.
## Traffic
After establishment, traffic boxes come from counter-nonce channels
(chab3 Sender/Receiver) keyed at completion; nothing rides outside the
encryption, and no traffic flows before establishment -- the state
machine rejects it. One kind byte heads every traffic box's plaintext;
see design.md for the kind namespace.
## Failures
Invalid data inside an otherwise authentic box (bad kdf tags, a bad
commitment reveal, low-order points, malformed bodies) proves the sender
knew the keys and misbehaved; the credential burns (see design.md).
AEAD failures prove no key knowledge and change nothing.
+107
View File
@@ -0,0 +1,107 @@
"""Benchmark: ChaCha20 encrypt via update() (returns bytes, extra memcpy from
the Rust temp buffer into the PyBytes) vs update_into() writing directly into
a caller-provided buffer.
Two scenarios:
one-shot : a fresh Cipher per call (mirrors chab3._session, one per message)
stream : a reused encryptor (mirrors chab3.random CSPRNG); isolates the
allocation+memcpy difference from cipher setup cost.
The bytes-hack row from test1 is absent: update_into rejects readonly buffers.
"fresh bytearray" has the same allocate-zeroed-buffer profile a hypothetical
fresh bytes target would have.
"""
import sys
import timeit
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
KEY = bytes(range(32))
NONCE = bytes(16)
SIZES = [16, 64, 256, 1024, 16384, 65536, 1048576]
REPEAT = 5
def iterations(size):
if size <= 1024:
return 5000
if size <= 16384:
return 1000
if size <= 65536:
return 300
if size <= 1 << 20:
return 20
return 1
def make_enc():
return Cipher(ChaCha20(KEY, NONCE), mode=None).encryptor()
def time_call(fn, number):
return min(timeit.repeat(fn, number=number, repeat=REPEAT)) / number
def oneshot(size):
data = bytes(size)
out = bytearray(size)
mv = memoryview(out)
n = iterations(size)
return {
"update -> bytes": time_call(lambda: make_enc().update(data), n),
"into fresh bytearray": time_call(
lambda: make_enc().update_into(data, bytearray(size)), n
),
"into reused bytearray": time_call(
lambda: make_enc().update_into(data, out), n
),
"into reused mv-slice": time_call(
lambda: make_enc().update_into(data, mv[:size]), n
),
}
def stream(size):
data = bytes(size)
out = bytearray(size)
mv = memoryview(out)
encs = [make_enc() for _ in range(4)]
n = iterations(size)
return {
"update -> bytes": time_call(lambda: encs[0].update(data), n),
"into fresh bytearray": time_call(
lambda: encs[1].update_into(data, bytearray(size)), n
),
"into reused bytearray": time_call(lambda: encs[2].update_into(data, out), n),
"into reused mv-slice": time_call(
lambda: encs[3].update_into(data, mv[:size]), n
),
}
def report(title, scenario):
print(f"\n=== {title} ===")
header = f"{'size':>9} | {'variant':<22} | {'ns/op':>12} | {'MB/s':>9} | vs update"
print(header)
print("-" * len(header))
for size in SIZES:
results = scenario(size)
base = results["update -> bytes"]
for name, t in results.items():
print(
f"{size:>9} | {name:<22} | {t * 1e9:>12.0f} | "
f"{size / t / 1e6:>9.1f} | {t / base:>8.3f}"
)
def main():
global SIZES
SIZES = [int(s, 0) for s in sys.argv[1:]] or SIZES
report("one-shot: fresh Cipher per call (chab3 message path)", oneshot)
report("stream: reused encryptor (chab3 CSPRNG path)", stream)
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
"""Test 1: does cryptography's into-API (CipherContext.update_into) accept an
immutable bytes object as the output buffer, despite bytes being read-only?
Python's buffer protocol will hand out a buffer descriptor for bytes (pyo3
requests PyBUF_FULL_RO), so the only thing stopping the write is an explicit
readonly check in cryptography's Rust binding. This script finds out, and
verifies correctness against the normal bytes-returning update() call.
"""
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
KEY = bytes(range(32))
NONCE = bytes(16)
DATA = b"The quick brown fox jumps over the lazy dog"
def fresh_encryptor():
return Cipher(ChaCha20(KEY, NONCE), mode=None).encryptor()
def main():
expected = fresh_encryptor().update(DATA)
# control: writable bytearray must work
buf = bytearray(len(DATA))
n = fresh_encryptor().update_into(DATA, buf)
assert bytes(buf) == expected and n == len(DATA)
print(f"control bytearray : ok ({n} bytes written)")
# the hack: immutable bytes as the output buffer.
# Fresh object: refcount 1, zero-filled, cached hash not yet computed,
# so no other code can observe the pre-mutation state.
out = bytes(len(DATA))
print(f"before bytes({len(DATA)}) : {out[:16].hex()}...")
try:
n = fresh_encryptor().update_into(DATA, out)
except Exception as e: # noqa: BLE001 -- probing behavior, any error is the answer
print(f"bytes-hack : REJECTED {type(e).__name__}: {e}")
return
print(f"bytes-hack : ACCEPTED ({n} bytes written)")
print(f"after bytes({len(DATA)}) : {out[:16].hex()}...")
print(f"mutated in place : {out != bytes(len(DATA))}")
print(f"matches update() result : {out == expected}")
# and via an explicit memoryview (readonly flag should propagate)
try:
fresh_encryptor().update_into(DATA, memoryview(bytes(len(DATA))))
except Exception as e: # noqa: BLE001 -- probing behavior, any error is the answer
print(f"memoryview(bytes) : REJECTED {type(e).__name__}: {e}")
else:
print("memoryview(bytes) : ACCEPTED")
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "quantumpipe"
version = "0.1.0"
description = "Quantum resistant hybrid ML-KEM-768 + X25519 key exchange with ChaCha20-Blake3 AEAD"
requires-python = ">=3.12"
dependencies = [
"blake3>=1.0.10",
"cryptography>=50.0.2",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=1.4.0",
"websockets>=17.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["quantumpipe"]
[tool.pyright]
disableBytesTypePromotions = false
venvPath = "."
venv = ".venv"
[tool.pytest.ini_options]
asyncio_mode = "auto"
+47
View File
@@ -0,0 +1,47 @@
"""quantumpipe -- PSK-bootstrapped hybrid ML-KEM-768 + X25519 key exchange
with a custom ChaCha20-Blake3 AEAD."""
from . import sas
from .chab3 import (
Receiver,
Sender,
decrypt,
decrypt_into,
encrypt,
encrypt_into,
nonce_increment,
)
from .exceptions import ProtocolError
from .kdf import kdf
from .sansio import (
Established,
Message,
Session,
Verification,
credential_id,
generate_registration_secret,
)
from .securebuf import SecureBuffer, wipe
from .ws import SecSocket
__all__ = [
"Established",
"Message",
"ProtocolError",
"Receiver",
"SecSocket",
"SecureBuffer",
"Sender",
"Session",
"Verification",
"credential_id",
"decrypt",
"decrypt_into",
"encrypt",
"encrypt_into",
"generate_registration_secret",
"kdf",
"nonce_increment",
"sas",
"wipe",
]
+222
View File
@@ -0,0 +1,222 @@
"""ChaCha20-Blake3 AEAD boxes, counter-nonce channels, and a fast CSPRNG.
Everything supports in-place operation into caller buffers."""
from __future__ import annotations
import hmac
import secrets
import threading
from collections.abc import Buffer
from typing import overload
import blake3
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
KEY_LEN = 32
NONCE_LEN = 12
_MAC_KEY_BLOCK = 64
def nonce_increment(nonce: Buffer) -> None:
"""Increment the nonce in place, little-endian."""
n = memoryview(nonce).cast("B")
for i in range(len(n)):
if n[i] < 255:
n[i] += 1
return
n[i] = 0
def _session(key: Buffer, nonce: Buffer, ad: Buffer | None, maclen: int):
"""ChaCha20 stream + keyed BLAKE3 with ad absorbed (None when maclen=0)."""
key = memoryview(key).cast("B")
nonce = memoryview(nonce).cast("B")
if len(key) != KEY_LEN:
raise ValueError("key must be 32 bytes")
if len(nonce) != NONCE_LEN:
raise ValueError("nonce must be 12 bytes")
if not 0 <= maclen <= 32:
raise ValueError("maclen must be in 0..32")
# IETF layout: 32-bit little-endian block counter || 96-bit nonce
stream = Cipher(ChaCha20(key, bytes(4) + nonce), mode=None).encryptor()
if maclen == 0:
return stream, None
tag_key = stream.update(bytes(_MAC_KEY_BLOCK))[:KEY_LEN]
mac = blake3.blake3(key=tag_key)
ad = memoryview(ad).cast("B") if ad else memoryview(b"")
mac.update(len(ad).to_bytes(8, "little"))
mac.update(ad)
return stream, mac
def _tag(mac, ct_len: int, maclen: int) -> bytes:
m = mac.copy()
m.update(ct_len.to_bytes(8, "little"))
return m.digest()[:maclen]
def encrypt_into(
out: Buffer,
msg: Buffer,
key: Buffer,
nonce: Buffer | None = None,
*,
ad: Buffer | None = None,
maclen: int = 16,
) -> memoryview:
"""Encrypt message under key, with optional nonce, ad and mac, into out buffer."""
msg = memoryview(msg).cast("B")
hdr = NONCE_LEN if nonce is None else 0
used = hdr + len(msg) + maclen
mv = memoryview(out).cast("B")
if len(mv) < used:
raise TypeError("output buffer too small")
out = mv[:used]
if nonce is None:
nonce = out[:NONCE_LEN]
random(nonce)
stream, mac = _session(key, nonce, ad, maclen)
ct = out[hdr : hdr + len(msg)]
stream.update_into(msg, ct)
if mac is not None:
mac.update(ct)
out[hdr + len(msg) :] = _tag(mac, len(msg), maclen)
return out
def decrypt_into(
out: Buffer,
ct: Buffer,
key: Buffer,
nonce: Buffer | None = None,
*,
ad: Buffer | None = None,
maclen: int = 16,
) -> memoryview:
"""Decrypt ciphertext into out buffer. Other arguments must match those at encryption point."""
ct = memoryview(ct).cast("B")
if nonce is None:
if len(ct) < NONCE_LEN + maclen:
raise ValueError("ciphertext buffer too short")
nonce, ct = ct[:NONCE_LEN], ct[NONCE_LEN:]
elif len(ct) < maclen:
raise ValueError("ciphertext buffer too short")
used = len(ct) - maclen
mv = memoryview(out).cast("B")
if len(mv) < used:
raise TypeError("output buffer too small")
out = mv[:used]
stream, mac = _session(key, nonce, ad, maclen)
if mac is not None:
mac.update(ct[:used])
if not hmac.compare_digest(bytes(ct[used:]), _tag(mac, used, maclen)):
raise ValueError("invalid authentication tag")
stream.update_into(ct[:used], out)
return out
def encrypt(
msg: Buffer,
key: Buffer,
nonce: Buffer | None = None,
*,
ad: Buffer | None = None,
maclen: int = 16,
) -> bytearray:
"""Encrypt message under key, with optional nonce, ad and mac, returning ciphertext."""
msg = memoryview(msg).cast("B")
out = bytearray(len(msg) + maclen + (NONCE_LEN if nonce is None else 0))
encrypt_into(out, msg, key, nonce, ad=ad, maclen=maclen)
return out
def decrypt(
ct: Buffer,
key: Buffer,
nonce: Buffer | None = None,
*,
ad: Buffer | None = None,
maclen: int = 16,
) -> bytearray:
"""Decrypt and return message. Other arguments must match those at encryption point."""
ct = memoryview(ct).cast("B")
hdr = NONCE_LEN if nonce is None else 0
if len(ct) < hdr + maclen:
raise ValueError("ciphertext buffer too short")
out = bytearray(len(ct) - hdr - maclen)
decrypt_into(out, ct, key, nonce, ad=ad, maclen=maclen)
return out
class _Sequence:
def __init__(self, key: Buffer, *, maclen: int = 16):
key = memoryview(key).cast("B")
if len(key) != KEY_LEN:
raise ValueError("key must be 32 bytes")
self._key = key
self._maclen = maclen
self._nonce = bytearray(NONCE_LEN)
class Sender(_Sequence):
"""Encrypting end of a message channel: messages are sent in order,
each individually authenticated. Pair with Receiver on the same key."""
def encrypt_into(
self, out: Buffer, msg: Buffer, *, ad: Buffer | None = None
) -> memoryview:
ret = encrypt_into(out, msg, self._key, self._nonce, ad=ad, maclen=self._maclen)
nonce_increment(self._nonce)
return ret
def encrypt(self, msg: Buffer, *, ad: Buffer | None = None) -> bytearray:
msg = memoryview(msg).cast("B")
out = bytearray(len(msg) + self._maclen)
self.encrypt_into(out, msg, ad=ad)
return out
class Receiver(_Sequence):
"""Decrypting end of a message channel: messages are verified and
returned in order. Pair with Sender on the same key."""
def decrypt_into(
self, out: Buffer, ct: Buffer, *, ad: Buffer | None = None
) -> memoryview:
ret = decrypt_into(out, ct, self._key, self._nonce, ad=ad, maclen=self._maclen)
nonce_increment(self._nonce)
return ret
def decrypt(self, ct: Buffer, *, ad: Buffer | None = None) -> bytearray:
ct = memoryview(ct).cast("B")
if len(ct) < self._maclen:
raise ValueError("ciphertext buffer too short")
out = bytearray(len(ct) - self._maclen)
self.decrypt_into(out, ct, ad=ad)
return out
_random = threading.local()
@overload
def random(nbytes: int, /) -> bytearray: ...
@overload
def random(buf: Buffer, /) -> None: ...
def random(arg: int | Buffer, /):
"""random(nbytes) -> nbytes as bytearray; random(buf) fills in place.
Seeded from OS random, thread safe."""
if isinstance(arg, int):
mv = memoryview(ret := bytearray(arg))
else:
mv, ret = memoryview(arg).cast("B"), None
zeroes = bytes(len(mv))
try:
_random.enc.update_into(zeroes, mv)
except (AttributeError, ValueError):
_random.enc = Cipher(
ChaCha20(secrets.token_bytes(KEY_LEN), bytes(16)), mode=None
).encryptor()
_random.enc.update_into(zeroes, mv)
return ret
+11
View File
@@ -0,0 +1,11 @@
"""ProtocolError, in its own module so transport wrappers (ws.py) and
applications can import it without the sans-io machinery."""
class ProtocolError(Exception):
"""Peer, wire, or protocol-misuse failure: log and disconnect."""
class _Burn(Exception):
# internal handshake -> session signal: the credential must burn
pass
+367
View File
@@ -0,0 +1,367 @@
"""Hybrid ML-KEM-768 + X25519 mutually authenticated key exchange on a
symmetric shared key, chab3-boxed throughout. Pure cryptography: a
Registration or Resumption object holds one exchange's per-connection
state, disposed by the caller at completion; sansio.py owns the state
machine, key store and policies. See docs/protocol.md.
"""
from __future__ import annotations
import hmac
import struct
from cryptography.hazmat.primitives.asymmetric import mlkem, x25519
from .chab3 import decrypt, encrypt, random
from .exceptions import _Burn
from .kdf import kdf
# -- fixed-size record layouts (struct handles packing + length checks) -----
# hybrid scheme = ML-KEM-768 (pk 1184 / sk-seed 64 / ct 1088) || X25519 (32)
_PUB = struct.Struct("1184s32s") # hybrid public key: 1216
_CAP = struct.Struct("1088s32s") # hybrid capsule: mlkem ct | fresh x25519 pub
_PUB_CAP = struct.Struct("1216s1120s") # hybrid public key | hybrid capsule
_TAG = struct.Struct("32s") # bare kdf tag
# handshake message bodies
_MSG1_REGISTER = struct.Struct("1216s32s") # e_alice | commitment
_STATIC = struct.Struct("1216s") # bare hybrid static public key
_MSG3_REGISTER_INNER = struct.Struct("1216s32s") # s_alice | reveal
_MSG4_REGISTER = struct.Struct("1120s32s") # se_cap | tb
_MSG2_RESUME = struct.Struct("1120s1120s32s") # sa_cap | ee_cap | tb
# msg2 = ee_cap | inner box(s_bob); msg3 = es_cap | inner box(s_alice | reveal);
# msg5 = _TAG (ta); msg1 resumption = _PUB_CAP (e_alice | sb_cap);
# msg3 resumption = _TAG (ta)
# kdf info strings: boxes are namespaced per flow and message (reg/msg1,
# res/msg2, reg/msg2/inner, ...) so the two exchanges are disjoint; other
# derivations are bare functional labels, shared between the flows when
# the inputs have the same format (a2b/b2a, idroot/*, cred/id).
# --------------------------------------------------------------------------
# hybrid KEM: ML-KEM-768 || X25519, operating on raw byte buffers only
#
# A private key is a single 32-byte idroot secret. Each scheme's key is
# derived from it with one domain-separated kdf call, so the two subkeys
# are independent PRF outputs and the idroot is the only thing held.
def _mlkem_private_key(idroot: bytes) -> mlkem.MLKEM768PrivateKey:
return mlkem.MLKEM768PrivateKey.from_seed_bytes(
kdf("idroot/mlkem", idroot, length=64)
)
def _x25519_private_key(idroot: bytes) -> x25519.X25519PrivateKey:
return x25519.X25519PrivateKey.from_private_bytes(kdf("idroot/x25519", idroot))
def _public_from_idroot(idroot: bytes) -> bytes:
return _PUB.pack(
_mlkem_private_key(idroot).public_key().public_bytes_raw(),
_x25519_private_key(idroot).public_key().public_bytes_raw(),
)
def _encapsulate(peer_public: bytes) -> tuple[bytes, bytes]:
# -> (shared_secret, capsule), each the concat of both schemes
mk_raw, xk_raw = _PUB.unpack(peer_public)
m_ss, m_ct = mlkem.MLKEM768PublicKey.from_public_bytes(mk_raw).encapsulate()
x_ek = x25519.X25519PrivateKey.generate()
try:
x_ss = x_ek.exchange(x25519.X25519PublicKey.from_public_bytes(xk_raw))
except ValueError:
raise ValueError("non-contributory X25519 public key") from None
return m_ss + x_ss, _CAP.pack(m_ct, x_ek.public_key().public_bytes_raw())
def _decapsulate(idroot: bytes, cap: bytes) -> bytes:
# ML-KEM has FIPS 203 implicit rejection (never fails; wrong key ->
# garbage caught by the kdf tags); X25519 does not, so a low-order
# result is rejected explicitly
m_ct, x_ct = _CAP.unpack(cap)
m_ss = _mlkem_private_key(idroot).decapsulate(m_ct)
try:
x_ss = _x25519_private_key(idroot).exchange(
x25519.X25519PublicKey.from_public_bytes(x_ct)
)
except ValueError:
raise ValueError("non-contributory X25519 capsule") from None
return m_ss + x_ss
def _fresh_ephemeral() -> tuple[bytes, bytes]:
# (root, public) for a fresh per-connection ephemeral key
root = random(32)
return root, _public_from_idroot(root)
def _traffic_keys(key: bytes, initiator: bool) -> tuple[bytes, bytes]:
# -> (send, recv); same input format in both flows -> same labels
a2b = kdf("a2b", key)
b2a = kdf("b2a", key)
return (a2b, b2a) if initiator else (b2a, a2b)
def _check(ok: bool, why: str) -> None:
if not ok:
raise _Burn(why)
def open_hello(msg1: bytes, *, shared: bytes, ad: bytes, flow: str) -> bytes:
# open a msg1 box under a candidate credential's shared key (the
# responder's caller scan); ValueError = not this credential
return decrypt(msg1, key=kdf(flow + "/msg1", shared), ad=ad)
# --------------------------------------------------------------------------
# ad-chained boxes: each box's associated data is the previous box's tag
class _Chain:
def __init__(self, ad: bytes):
self._ad = ad
def encrypt(self, body: bytes, *, key: bytes) -> bytes:
msg = encrypt(body, key=key, ad=self._ad)
self._ad = msg[-16:]
return bytes(msg)
def decrypt(self, msg: bytes, *, key: bytes) -> bytes:
plain = decrypt(msg, key=key, ad=self._ad)
self._ad = msg[-16:]
return plain
def advance(self, msg: bytes) -> None:
# a box decrypted outside the chain (the responder's caller scan)
self._ad = msg[-16:]
# --------------------------------------------------------------------------
# the two exchanges. Stage names carry the acting party:
# alice drives with startA/respondA (+ confirmA when registering),
# bob with respondB (+ confirmB/finishB when registering, completeB
# when resuming)
class Registration:
"""Registration exchange (first connection): every leg is the same
hybrid KEM operation -- ee_cap for forward secrecy, es_cap/se_cap
challenging the statics passed late (s_bob inside msg2's inner box,
s_alice inside msg3's). finish() -> (send, recv, rotated shared,
sas, peer pub)."""
def __init__(self, shared: bytes, idroot: bytes, ad: bytes):
self._shared = shared
self._idroot = idroot
self._static_pub = _public_from_idroot(idroot)
self._chain = _Chain(ad)
def _root(self) -> bytes:
return kdf("reg/root", self._shared, self._ee, self._es, self._se)
# -- alice ----------------------------------------------------------------
def startA(self) -> bytes:
# msg1: the ephemeral key, committed under a reveal hidden until msg3
self._ephemeral_root, e_alice = _fresh_ephemeral()
self._reveal = random(32)
body = _MSG1_REGISTER.pack(e_alice, kdf("commit", e_alice, self._reveal))
return self._chain.encrypt(body, key=kdf("reg/msg1", self._shared))
def respondA(self, msg2: bytes) -> bytes:
# open bob's reply -- an outer AEAD failure burns nothing, it
# proves no key knowledge -- then the static inside its inner
# box, and challenge it
plain = self._chain.decrypt(msg2, key=kdf("reg/msg2", self._shared))
try:
ee_cap, inner = plain[: _CAP.size], plain[_CAP.size :]
ee = _decapsulate(self._ephemeral_root, ee_cap)
(s_bob,) = _STATIC.unpack(decrypt(inner, key=kdf("reg/msg2/inner", ee)))
es, es_cap = _encapsulate(s_bob) # challenge: bob's static
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg2 contents ({e})") from None
self._peer_pub = s_bob
self._ee = ee
self._es = es
return self._chain.encrypt(
es_cap
+ encrypt(
_MSG3_REGISTER_INNER.pack(self._static_pub, self._reveal),
key=kdf("reg/msg3/inner", es),
),
key=kdf("reg/msg3", self._shared, ee),
)
def confirmA(self, msg4: bytes) -> tuple[bytes, bytes]:
# bob's challenge to our static plus his root confirmation. The
# caller gates the answer (msg5) on the out-of-band comparison.
plain = self._chain.decrypt(msg4, key=kdf("reg/msg4", self._shared, self._ee))
try:
se_cap, tb = _MSG4_REGISTER.unpack(plain)
se = _decapsulate(self._idroot, se_cap)
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg4 contents ({e})") from None
self._se = se
_check(
hmac.compare_digest(tb, kdf("reg/confirm-b", self._root())),
"responder tag mismatch",
)
sas = kdf("sas", self._shared, self._ee, self._reveal, length=4)
msg5 = self._chain.encrypt(
kdf("reg/confirm-a", self._root()),
key=kdf("reg/msg5", self._shared, self._ee, self._es),
)
return msg5, sas
# -- bob ------------------------------------------------------------------
def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# msg2 from an opened msg1 (open_hello did the caller scan)
self._chain.advance(msg1)
try:
e_alice, commitment = _MSG1_REGISTER.unpack(opened)
ee, ee_cap = _encapsulate(e_alice) # forward secrecy
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg1 contents ({e})") from None
self._commitment = (e_alice, commitment)
body = ee_cap + encrypt(
_STATIC.pack(self._static_pub), key=kdf("reg/msg2/inner", ee)
)
self._ee = ee
return self._chain.encrypt(body, key=kdf("reg/msg2", self._shared))
def confirmB(self, msg3: bytes) -> tuple[bytes, bytes]:
# alice's challenge to our static, her static and her reveal. The
# caller gates the answer (msg4) on the out-of-band comparison.
plain = self._chain.decrypt(msg3, key=kdf("reg/msg3", self._shared, self._ee))
try:
es_cap, inner = plain[: _CAP.size], plain[_CAP.size :]
es = _decapsulate(self._idroot, es_cap) # proves our static
s_alice, reveal = _MSG3_REGISTER_INNER.unpack(
decrypt(inner, key=kdf("reg/msg3/inner", es))
)
se, se_cap = _encapsulate(s_alice)
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg3 contents ({e})") from None
e_alice, commitment = self._commitment
_check(
hmac.compare_digest(commitment, kdf("commit", e_alice, reveal)),
"commitment reveal mismatch",
)
self._reveal = reveal
self._peer_pub = s_alice
self._es = es
self._se = se
sas = kdf("sas", self._shared, self._ee, reveal, length=4)
msg4 = self._chain.encrypt(
_MSG4_REGISTER.pack(se_cap, kdf("reg/confirm-b", self._root())),
key=kdf("reg/msg4", self._shared, self._ee),
)
return msg4, sas
def finishB(self, msg5: bytes) -> None:
# alice's root confirmation: proves she decapsulated se_cap
plain = self._chain.decrypt(
msg5, key=kdf("reg/msg5", self._shared, self._ee, self._es)
)
try:
(ta,) = _TAG.unpack(plain)
except struct.error as e:
raise _Burn(f"bad msg5 contents ({e})") from None
_check(
hmac.compare_digest(ta, kdf("reg/confirm-a", self._root())),
"initiator tag mismatch",
)
# -- completion -------------------------------------------------------------
def finish(self, initiator: bool) -> tuple[bytes, bytes, bytes, bytes, bytes]:
# -> (send, recv, rotated shared, sas, peer pub); the rotation is
# held by the caller until the store swap
root = self._root()
rotated = kdf("rotate", root)
sas = kdf("sas", self._shared, self._ee, self._reveal, length=4)
return *_traffic_keys(root, initiator), rotated, sas, self._peer_pub
class Resumption:
"""Resumption exchange: statics proven by decapsulation, never on
the wire; all legs hybrid. Alice: startA/respondA; bob:
respondB/completeB; finish() -> (send, recv)."""
def __init__(self, shared: bytes, idroot: bytes, peer_pub: bytes, ad: bytes):
self._shared = shared
self._idroot = idroot
self._peer_pub = peer_pub # proven by decapsulation
self._chain = _Chain(ad)
# -- alice ----------------------------------------------------------------
def startA(self) -> bytes:
# msg1: the ephemeral key plus an encapsulation to bob's static
# (the responder ships no ephemeral of his own: no leg uses it)
self._ephemeral_root, e_alice = _fresh_ephemeral()
self._sb, sb_cap = _encapsulate(self._peer_pub)
body = _PUB_CAP.pack(e_alice, sb_cap)
return self._chain.encrypt(body, key=kdf("res/msg1", self._shared))
def respondA(self, msg2: bytes) -> bytes:
# open bob's reply, decapsulate all three legs
plain = self._chain.decrypt(msg2, key=kdf("res/msg2", self._shared, self._sb))
try:
sa_cap, ee_cap, tb = _MSG2_RESUME.unpack(plain)
sa = _decapsulate(self._idroot, sa_cap) # proves our static
ee = _decapsulate(self._ephemeral_root, ee_cap) # forward secrecy
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg2 contents ({e})") from None
_check(
hmac.compare_digest(tb, kdf("res/tag-b", self._sb, sa, ee)),
"responder tag mismatch",
)
self._sa = sa
self._ee = ee
return self._chain.encrypt(
kdf("res/tag-a", self._sb, sa, ee),
key=kdf("res/msg3", self._shared, self._sb, sa, ee),
)
# -- bob ------------------------------------------------------------------
def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# prove our static, prove alice's, forward secrecy
self._chain.advance(msg1)
try:
e_alice, sb_cap = _PUB_CAP.unpack(opened)
sb = _decapsulate(self._idroot, sb_cap) # proves our static
sa, sa_cap = _encapsulate(self._peer_pub) # proves peer's static
ee, ee_cap = _encapsulate(e_alice) # forward secrecy
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg1 contents ({e})") from None
self._sb = sb
self._sa = sa
self._ee = ee
body = _MSG2_RESUME.pack(sa_cap, ee_cap, kdf("res/tag-b", sb, sa, ee))
return self._chain.encrypt(body, key=kdf("res/msg2", self._shared, sb))
def completeB(self, msg3: bytes) -> None:
# alice answers with just her tag
plain = self._chain.decrypt(
msg3, key=kdf("res/msg3", self._shared, self._sb, self._sa, self._ee)
)
try:
(ta,) = _TAG.unpack(plain)
except struct.error as e:
raise _Burn(f"bad msg3 contents ({e})") from None
_check(
hmac.compare_digest(ta, kdf("res/tag-a", self._sb, self._sa, self._ee)),
"initiator tag mismatch",
)
# -- completion -------------------------------------------------------------
def finish(self, initiator: bool) -> tuple[bytes, bytes]:
# -> (send, recv); a resumption rotates nothing
key = kdf("res/session-key", self._shared, self._sb, self._sa, self._ee)
return _traffic_keys(key, initiator)
+19
View File
@@ -0,0 +1,19 @@
"""Length-framed SHA-512 KDF (docs/protocol.md)."""
from __future__ import annotations
import hashlib
__all__ = ["kdf"]
def kdf(*data: str | bytes, length: int = 32) -> bytes:
"""kdf(info, *items, length<=64) -> SHA-512 over length-framed items."""
if not 1 <= length <= 64:
raise ValueError("length must be in 1..64")
items = [d.encode() if isinstance(d, str) else bytes(d) for d in data]
h = hashlib.sha512(len(items).to_bytes(8, "little"))
for item in items:
h.update(len(item).to_bytes(8, "little"))
h.update(item)
return h.digest()[:length]
+433
View File
@@ -0,0 +1,433 @@
"""The quantumpipe protocol state machine, sans I/O. session()
initiates ("alice"), session(msg1) responds ("bob"); results are bytes
to send or signal objects; once established, session(Message) encrypts a
frame and session(frame) decrypts one back. See docs/design.md."""
from __future__ import annotations
import logging
import struct
from collections.abc import MutableMapping
from dataclasses import dataclass
from .chab3 import Receiver, Sender, random
from .exceptions import ProtocolError, _Burn
from .handshake import Registration, Resumption, open_hello
from .kdf import kdf
_log = logging.getLogger(__name__)
# one-line, secret-free rendering of call inputs/results for the log
def _describe(obj) -> str:
if obj is None:
return "-"
if isinstance(obj, (bytes, bytearray)):
return f"frame[{len(obj)}]"
if isinstance(obj, Message):
return f"Message(kind={obj.kind}, {len(obj.data)}B)"
if isinstance(obj, Verification):
return f"Verification({obj}, answer={obj.answer})"
return type(obj).__name__
# --------------------------------------------------------------------------
# credentials
#
# generate_registration_secret() creates the one-time registration secret,
# given to both parties out of band and stored as a bare 32-byte store
# entry. The first successful registration replaces it with the composite.
_CREDENTIAL = struct.Struct("32s32s1216s4s?") # idroot | shared | peer pub | sas | vf
def generate_registration_secret() -> bytes:
"""Fresh 256-bit registration secret, given to both parties once."""
return random(32)
def credential_id(cred: bytes) -> bytes:
"""A stable 16-byte identifier for a stored credential (recomputable
from the record); rotates when the credential does."""
if len(cred) == _CREDENTIAL.size:
cred = cred[:-1] # exclude the verified flag
return kdf("cred/id", cred, length=16)
def _credential_shared(cred: bytes) -> bytes:
# the bare secret itself, or the composite's rotated field
return cred if len(cred) == 32 else _CREDENTIAL.unpack(cred)[1]
def _same_credential(a: bytes, b: bytes) -> bool:
# same credential, ignoring the verified flag byte on composites
if len(a) != len(b):
return False
if len(a) == _CREDENTIAL.size:
return a[:-1] == b[:-1]
return a == b
# --------------------------------------------------------------------------
# signals and traffic
@dataclass
class Verification:
"""Out-of-band comparison request (registration only): fill .answer
and pass back -- True = verified, None = proceed unverified, False =
reject (the credential is deleted)."""
sas: bytes
answer: bool | None = None
def __str__(self) -> str:
return self.sas.hex()
@dataclass(frozen=True)
class Established:
"""Handshake complete: sas = the credential's comparison digest,
verified = its flag at establishment."""
sas: bytes
verified: bool
@dataclass(frozen=True)
class Message:
"""One traffic frame: an opaque user kind plus bytes. Kind 0 is
reserved for in-band protocol messages."""
data: bytes
kind: int
# dispatch in/out types
_In = bytes | Message | Verification | None
_Out = bytes | Message | Verification | Established
# --------------------------------------------------------------------------
# the state machine
class Session:
"""One end of the protocol state machine; call it to advance.
store maps peer ids to credentials; key_id selects the credential to
initiate with (none -> respond); ad is application session context,
bound into msg1 (both sides must pass the same value).
"""
def __init__(self, store: MutableMapping, key_id=None, /, ad: bytes = b""):
self._store = store
self._key_id = None
self._ad = ad
self._registering: bool | None = None # unknown until a credential loads
self._exchange: Registration | Resumption | None = None
self._initiator: bool | None = None # unknown until the first call
self._pending: bytes | None = None # a tail message held for the gate
self._verified_answer = False
self._phase = "hello"
if key_id is not None:
self._load_credential(key_id)
def __call__(self, msg: _In = None, /) -> list[_Out]:
_log.debug("call state=%s input=%s", self.state, _describe(msg))
try:
if isinstance(msg, Message): # outbound user traffic
out = self._st_data(msg)
else:
match self._phase:
case "hello":
out = self._st_hello(msg)
case "msg2":
out = self._st_msg2(msg)
case "msg3":
out = self._st_msg3(msg)
case "msg4":
out = self._st_msg4(msg)
case "msg5":
out = self._st_msg5(msg)
case "verify":
out = self._st_verify(msg)
case "established": # inbound user traffic
out = self._st_data(msg)
case _:
raise ProtocolError(f"unexpected input in state {self.state}")
except _Burn as b:
# invalid data inside a validly opened box: the sender knew
# the keys and misbehaved -- retire the credential
self._delete_credential()
_log.debug("fail state=%s: credential burned: %s", self.state, b)
raise ProtocolError(f"credential burned: {b}") from None
except (ValueError, struct.error) as e:
_log.debug("fail state=%s: %r", self.state, e)
raise ProtocolError(str(e)) from None
_log.debug(
"emit state=%s results=[%s]",
self.state,
", ".join(_describe(r) for r in out),
)
return out
# -- outcome ------------------------------------------------------------
@property
def state(self) -> str:
"""mode:phase, e.g. "reg:msg2"; "established"/"failed" at the end."""
if self._phase in ("established", "failed") or self._registering is None:
return self._phase
mode = "reg" if self._registering else "auth"
return f"{mode}:{self._phase}"
@property
def verification(self) -> Verification:
"""The out-of-band comparison value (*:verify, or established
over a still-unverified credential)."""
if self._phase == "verify" or (
self._phase == "established" and not self._credential_verified
):
return Verification(self._sas)
raise ProtocolError(f"nothing to verify in state {self.state}")
@property
def verified(self) -> bool:
"""Whether the session was verified (established only)."""
if self._phase != "established":
raise ProtocolError("handshake not complete")
return self._verified
@property
def credential(self) -> bytes:
"""The stored credential for this session's peer."""
if self._phase != "established":
raise ProtocolError("handshake not complete")
return self._store[self._key_id]
def confirm(self, verified: bool) -> None:
"""Post-establishment decision on a still-unverified credential:
True flags it verified, False deletes it. The session itself is
unaffected."""
if self._phase != "established" or self._credential_verified:
raise ProtocolError(f"nothing to confirm in state {self.state}")
if verified:
current = self._store.get(self._key_id)
if current is None or not _same_credential(current, self._stored):
raise ProtocolError("credential changed underneath us")
self._swap_credential(verified=True)
self._verified = True
else:
# rejected: the credential is deleted outright -- a genuine
# mismatch means a MITM; re-provision a fresh PSK
self._delete_credential()
# -- credential loading --------------------------------------------------
def _load_credential(self, key_id) -> Registration | Resumption:
# bad local credential -> caller's problem (KeyError/struct.error)
cred = self._store[key_id]
self._key_id = key_id
self._stored = cred # CAS reference for every later store write
if len(cred) == 32: # bare registration secret
self._idroot = random(32) # fresh identity root
self._shared = cred
self._registering = True
self._credential_verified = False
hs: Registration | Resumption = Registration(cred, self._idroot, self._ad)
else:
(
self._idroot,
self._shared,
self._peer_pub,
self._sas,
self._credential_verified,
) = _CREDENTIAL.unpack(cred)
self._registering = False
hs = Resumption(self._shared, self._idroot, self._peer_pub, self._ad)
self._exchange = hs
return hs
# -- store mutations -------------------------------------------------------
#
# Every write is a compare-and-swap against the credential this session
# loaded (self._stored): with several connections in flight on one
# store, only the first to reach the atomic key change wins, and a
# loser's later burn/delete/flag-flip never touches a slot that
# changed underneath it. The store is synchronously accessed (a
# plain dict), so these checks are atomic by construction.
def _swap_credential(self, verified: bool) -> None:
# atomic credential replacement in the store; the application
# persists the store itself if it wants durability
self._credential_verified = verified
self._stored = _CREDENTIAL.pack(
self._idroot, self._shared, self._peer_pub, self._sas, verified
)
self._store[self._key_id] = self._stored
def _delete_credential(self) -> None:
# delete only if the slot still holds OUR credential: a parallel
# connection may have won the slot in the meantime
current = self._store.get(self._key_id)
if current is not None and _same_credential(current, self._stored):
del self._store[self._key_id]
# -- state handlers ------------------------------------------------------
def _st_hello(self, msg: _In) -> list[_Out]:
if msg is None: # initiate ("alice")
hs = self._exchange
if hs is None:
raise ProtocolError("initiation requires a key_id")
self._initiator = True
self._phase = "msg2"
return [hs.startA()]
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg1 in state {self.state}")
# respond ("bob"): identify the caller by trying msg1 against
# every credential in the store -- an AEAD open per candidate is
# cheap, and a box that opens pins the credential (and its mode).
# No candidate opening proves no key knowledge: nothing burns.
for key_id, cred in self._store.items():
try:
opened = open_hello(
msg,
shared=_credential_shared(cred),
ad=self._ad,
flow="reg" if len(cred) == 32 else "res",
)
except (struct.error, ValueError):
continue # not this credential (or a malformed store entry)
break
else:
raise ProtocolError("invalid authentication tag")
self._initiator = False
self._phase = "msg3"
return [self._load_credential(key_id).respondB(msg, opened)]
def _st_msg2(self, msg: _In) -> list[_Out]:
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg2 in state {self.state}")
hs = self._exchange
assert hs is not None # a credential is loaded by now
msg3 = hs.respondA(msg)
if self._registering: # registration continues to msg4
self._phase = "msg4"
return [msg3]
return [msg3, *self._keyed(hs, initiator=True)]
def _st_msg3(self, msg: _In) -> list[_Out]:
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg3 in state {self.state}")
hs = self._exchange
assert hs is not None # a credential is loaded by now
if self._registering:
assert isinstance(hs, Registration)
# msg4 is held back until the comparison answer
self._pending, self._sas = hs.confirmB(msg)
self._phase = "verify"
return [Verification(self._sas)]
assert isinstance(hs, Resumption)
hs.completeB(msg)
return self._keyed(hs, initiator=False)
def _st_msg4(self, msg: _In) -> list[_Out]:
# registration only: bob's challenge and confirmation arrive;
# msg5 is held back until the comparison answer
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg4 in state {self.state}")
hs = self._exchange
assert isinstance(hs, Registration)
self._pending, self._sas = hs.confirmA(msg)
self._phase = "verify"
return [Verification(self._sas)]
def _st_msg5(self, msg: _In) -> list[_Out]:
# registration only: the initiator's confirmation finishes the
# exchange -- the key change happens here
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg5 in state {self.state}")
hs = self._exchange
assert isinstance(hs, Registration)
hs.finishB(msg)
self._install_keys(hs, initiator=False)
self._swap_at_gate(self._verified_answer)
return [self._establish(verified=self._verified_answer)]
def _st_verify(self, msg: _In) -> list[_Out]:
# registration only: the comparison answer gates the tail of the
# exchange. False deletes the credential and fails; True and
# None both proceed (verified / unverified).
if not isinstance(msg, Verification):
raise ProtocolError(f"expecting the comparison answer in {self.state}")
if msg.answer is False:
self._delete_credential()
self._phase = "failed"
raise ProtocolError("verification rejected")
hs = self._exchange
assert isinstance(hs, Registration) # the resumption flow never gates
pending = self._pending
assert pending is not None # set by the gate that led here
if self._initiator:
# everything is in: key change, traffic channels, established
self._install_keys(hs, initiator=True)
self._swap_at_gate(msg.answer is True)
return [pending, self._establish(verified=msg.answer is True)]
# responder: release the held msg4; the key change waits for msg5
self._verified_answer = msg.answer is True
self._phase = "msg5"
return [pending]
def _swap_at_gate(self, verified: bool) -> None:
# the composite atomically replaces the registration secret,
# flagged by the answer; the swap is the arbitration point for
# parallel registrations -- the first swap wins, a later one
# finds the registration secret gone and fails
if self._store.get(self._key_id) != self._stored:
self._phase = "failed"
raise ProtocolError("registration key already spent")
self._swap_credential(verified=verified)
def _keyed(self, hs: Registration | Resumption, initiator: bool) -> list[_Out]:
# resumption only: install the traffic channels, no comparison
self._install_keys(hs, initiator)
return [self._establish(verified=self._credential_verified)]
def _st_data(self, msg: _In) -> list[_Out]:
# traffic: Messages encrypt into wire frames, frames decrypt
# back to Messages; the kind byte is opaque here (0 = protocol)
if self._phase != "established":
raise ProtocolError(f"no traffic in state {self.state}")
if isinstance(msg, Message):
if msg.kind == 0:
raise ProtocolError("frame kind 0 is reserved for protocol use")
return [bytes(self._tx.encrypt(bytes([msg.kind]) + msg.data))]
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting traffic in state {self.state}")
plain = self._rx.decrypt(msg)
if not plain:
raise ProtocolError("empty traffic frame")
kind, data = plain[0], bytes(plain[1:])
if kind == 0:
# in-band protocol messages will be handled here
raise ProtocolError("frame kind 0 is reserved for protocol use")
return [Message(data, kind)]
# -- transitions shared by both roles ------------------------------------
def _install_keys(self, hs: Registration | Resumption, initiator: bool) -> None:
# pull the results out of the exchange and dispose of its state
self._exchange = None
if isinstance(hs, Registration):
send, recv, self._shared, self._sas, self._peer_pub = hs.finish(initiator)
# rotated shared in memory only: the store swap waits for the answer
else:
send, recv = hs.finish(initiator)
self._tx, self._rx = Sender(send), Receiver(recv)
def _establish(self, verified: bool) -> Established:
self._verified = verified
self._phase = "established"
return Established(self._sas, verified)
+21
View File
@@ -0,0 +1,21 @@
"""SAS rendering: map digest bytes to emoji rows from the table below."""
# fmt: off
# ruff: noqa
_EMOJIS: str = '😆😍😭😴😉😇😎🤔😡🤡👻👽🤖💀💩👀👍🙏🚶🏃💃🤷🤦✨⭐🌙🌈🔥💧🌊🌀🌂🐱🐶🐭🐹🐰🦊🐻🐼🐨🦥🦨🦁🐮🐷🐸🐵🐔🐧🐤🦆🦅🦉🦇🐺🐴🦄🐝🐛🐌🐞🐜🪰🦂🦀🦞🦑🐍🦎🐢🐟🐬🐳🦈🐙🐚🐊🐉🦖🐫🦒🦘🐘🦏🐐🐏🐑🐒🐓🦃🦚🦜🦢🦩🐾🌵🌲🌳🌴🌱🌿🍀🍁🍃🌹🌷🌻💐🍄🌰🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🍍🥝🍅🍆🥑🥦🌽🥕🥔🍞🧀🥚🥞🧇🍔🍟🍕🌭🌮🌯🍚🍛🍜🍝🍣🍪🍰🍩🍬🍭🍯🍵🍶🍺🍷🍸🧃⚽🏀🏈⚾🎾🏐🏉🎱⛳🏆🏅🎯🎳🛷🪁🚗🚕🚌🚚🚜🚲🚆🚇🚊🚀🚁🛸⛵🚢⚓⌚📱💻📷📹📺📻⏰💡🔦🔋🔌📖📌📎🔒🔑🔨🔧🧲🔔🎁🎈🎤🎧🎸🥁🎺🎹🎨🎪🎲🧩💎👑🏠🏫🏦🏰🏭⛪⛲⛺🌉🌋🌍🧭⌛📅📦📁📊💰💼🏹🔭🔬🔮🪑🚽🚿🛁🧹🧺🧽🪣👕👗'
_NAMES = 'laugh love cry sleep wink halo cool think angry clown ghost alien robot skull poop eyes thumbsup pray walk run dance shrug facepalm sparkle star moon rainbow fire drop wave cyclone umbrella cat dog mouse hamster rabbit fox bear panda koala sloth skunk lion cow pig frog monkey chicken penguin chick duck eagle owl bat wolf horse unicorn bee bug snail beetle ant fly scorpion crab lobster squid snake lizard turtle fish dolphin whale shark octopus shell crocodile dragon dino camel giraffe kangaroo elephant rhino goat ram sheep ape rooster turkey peacock parrot swan flamingo paw cactus pine tree palm plant herb clover maple leaf rose tulip sunflower bouquet mushroom chestnut apple pear orange lemon banana melon grape strawberry cherry peach pineapple kiwi tomato eggplant avocado broccoli corn carrot potato bread cheese egg pancake waffle burger fries pizza hotdog taco burrito rice curry ramen spaghetti sushi cookie cake donut candy lollipop honey tea sake beer wine martini juice soccer basketball football baseball tennis volley rugby snooker golf trophy medal target bowling sled kite car taxi bus truck tractor bike train metro tram rocket helicopter ufo boat ship anchor watch phone laptop camera video tv radio clock bulb flashlight battery plug book pin clip lock key hammer wrench magnet bell gift balloon microphone headphones guitar drum trumpet piano palette circus dice puzzle diamond crown house school bank castle factory church fountain tent bridge volcano globe compass hourglass calendar box folder chart money briefcase bow telescope microscope crystal chair toilet shower bathtub broom basket sponge bucket shirt dress'.split()
# fmt: on
assert len(_EMOJIS) == len(_NAMES)
def format(data: bytes) -> str:
"""Render bytes as table emojis, two per row, names as outer labels;
an odd trailing byte leaves the bottom right vacant."""
rows = []
for i in range(0, len(data), 2):
row = f"{_NAMES[data[i]]:>10}{_EMOJIS[data[i]]}"
if i + 1 < len(data):
row += f"{_EMOJIS[data[i + 1]]}{_NAMES[data[i + 1]]:<10}"
rows.append(row)
return "\n".join(rows)
+100
View File
@@ -0,0 +1,100 @@
"""SecureBuffer -- mlock'd, core-dump-excluded, wipe-on-close memory.
Unused by the package itself; kept for applications."""
import ctypes
import mmap
import os
from contextlib import ExitStack, suppress
def wipe(buf):
n = memoryview(buf).cast("B")
n[:] = b"\xff" * len(n)
class SecureBuffer:
def __init__(self, size: int):
with ExitStack() as defer:
self._mmap = mmap.mmap(-1, size, access=mmap.ACCESS_WRITE)
defer.callback(self._mmap.close)
self._lock()
defer.callback(self._unlock)
self._dontdump()
defer.pop_all()
@property
def closed(self):
return self._mmap.closed
def _addr(self):
return ctypes.addressof(ctypes.c_char.from_buffer(self._mmap))
def _lock(self):
addr = self._addr()
size = len(self._mmap)
if os.name == "nt":
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.VirtualLock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
api.VirtualLock.restype = ctypes.c_bool
if not api.VirtualLock(addr, size):
raise ctypes.WinError(ctypes.get_last_error())
else:
libc = ctypes.CDLL(None, use_errno=True)
libc.mlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
libc.mlock.restype = ctypes.c_int
if libc.mlock(addr, size):
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
def _unlock(self):
addr = self._addr()
size = len(self._mmap)
if os.name == "nt":
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.VirtualUnlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
api.VirtualUnlock.restype = ctypes.c_bool
if not api.VirtualUnlock(addr, size):
raise ctypes.WinError(ctypes.get_last_error())
else:
libc = ctypes.CDLL(None, use_errno=True)
libc.munlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
libc.munlock.restype = ctypes.c_int
if libc.munlock(addr, size):
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
def _dontdump(self):
if hasattr(self._mmap, "madvise") and hasattr(mmap, "MADV_DONTDUMP"):
self._mmap.madvise(mmap.MADV_DONTDUMP)
def close(self):
if self.closed:
return
self._mmap[:] = b"\xff" * len(self._mmap)
self._unlock()
self._mmap.close()
def __buffer__(self, flags):
return memoryview(self._mmap)
def __getitem__(self, key):
return memoryview(self._mmap)[key]
def __setitem__(self, key, value):
memoryview(self._mmap)[key] = value
def __bytes__(self):
raise TypeError("SecureBuffer cannot be converted to bytes")
def __len__(self):
return len(self._mmap)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def __del__(self):
with suppress(Exception):
self.close()
+124
View File
@@ -0,0 +1,124 @@
"""SecSocket: a quantumpipe Session pumped over a WebSocket (either the
`websockets` library or FastAPI/Starlette). send() picks the frame kind
from the object (str -> TEXT, bytes -> BINARY, Message passes through),
receive() maps it back. See docs/design.md."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from .exceptions import ProtocolError
from .sansio import Established, Message, Session, Verification
_log = logging.getLogger(__name__)
Handler = Callable[[Any], Awaitable[Any]]
# this wrapper's user kinds; kind 0 is reserved for in-band protocol use
BINARY = 1 # opaque bytes, delivered as bytes
TEXT = 2 # utf-8 text, delivered decoded as str
class SecSocket:
"""A quantumpipe Session pumped over a WebSocket; the Session is
exposed as .session (state, verified, verification, confirm,
credential)."""
def __init__(
self,
ws: Any,
store: MutableMapping,
key_id: Any = None,
*,
ad: bytes = b"",
handlers: dict[type, Handler] | None = None,
):
self._ws = ws
self.session = Session(store, key_id, ad=ad)
self._established = asyncio.Event()
self._handlers = handlers or {}
async def accept(self) -> None:
"""Server side: accept the socket if it needs accepting."""
if hasattr(self._ws, "accept"):
await self._ws.accept()
await self._run(initiate=False)
async def connect(self) -> None:
"""Client side: initiate the handshake."""
await self._run(initiate=True)
async def send(self, data: bytes | str | Message) -> None:
"""str -> TEXT frame, bytes -> BINARY frame, Message passes
through with its own kind."""
if isinstance(data, Message):
msg = data
elif isinstance(data, str):
msg = Message(data.encode(), TEXT)
else:
msg = Message(bytes(data), BINARY)
await self._dispatch(self.session(msg))
async def receive(self) -> bytes | str | Message:
"""BINARY -> bytes, TEXT -> str, other kinds -> raw Message."""
(msg,) = self.session(await self._recv())
if not isinstance(msg, Message):
raise ProtocolError("expected a traffic frame")
if msg.kind == BINARY:
return msg.data
if msg.kind == TEXT:
try:
return bytes(msg.data).decode()
except UnicodeDecodeError as e:
raise ProtocolError(f"invalid utf-8 in text frame: {e}") from None
return msg
async def close(self) -> None:
await self._ws.close()
async def _run(self, initiate: bool) -> None:
try:
if initiate:
await self._dispatch(self.session())
while not self._established.is_set():
await self._dispatch(self.session(await self._recv()))
except BaseException:
await self._ws.close()
raise
async def _dispatch(self, results: list) -> None:
for r in results:
if isinstance(r, bytes):
_log.debug("ws send frame[%d]", len(r))
await self._send(r)
continue
_log.debug("handle %s", type(r).__name__)
if isinstance(r, Established):
self._established.set()
handler = self._handlers.get(type(r))
if handler is not None:
out = await handler(r)
if out is not None and isinstance(r, Verification):
r.answer = out
if isinstance(r, Verification): # the answered result resumes the session
await self._dispatch(self.session(r))
# the two supported WebSocket flavors: the `websockets` library
# (send/recv) and FastAPI/Starlette (accept/send_bytes/receive_bytes)
async def _send(self, data: bytes) -> None:
if hasattr(self._ws, "send_bytes"): # FastAPI/Starlette
await self._ws.send_bytes(data)
else: # websockets library: send(str | bytes)
await self._ws.send(data)
async def _recv(self) -> bytes:
if hasattr(self._ws, "receive_bytes"): # FastAPI/Starlette
return await self._ws.receive_bytes()
data = await self._ws.recv() # websockets library
if not isinstance(data, bytes):
raise ProtocolError(f"expected a binary frame, got {type(data).__name__}")
return data
+4
View File
@@ -0,0 +1,4 @@
import logging
# show all handshake/channel events when tests run (-o log_cli=true to see)
logging.getLogger("quantumpipe").setLevel(logging.DEBUG)
+167
View File
@@ -0,0 +1,167 @@
import secrets
import pytest
from quantumpipe.chab3 import (
NONCE_LEN,
Receiver,
Sender,
decrypt,
decrypt_into,
encrypt,
encrypt_into,
)
KEY = secrets.token_bytes(32)
NONCE = secrets.token_bytes(12)
AD = secrets.token_bytes(7)
MSG = b"streaming payload, split " + b"into two chunks"
def test_roundtrip():
ct = encrypt(MSG, KEY, NONCE, ad=AD)
assert isinstance(ct, bytearray)
assert len(ct) == len(MSG) + 16
pt = decrypt(ct, KEY, NONCE, ad=AD)
assert isinstance(pt, bytearray)
assert pt == MSG
def test_into_variants():
ref = encrypt(MSG, KEY, NONCE, ad=AD)
buf = bytearray(len(MSG) + 16 + 10) # oversized buffer
used = encrypt_into(buf, MSG, KEY, NONCE, ad=AD)
assert bytes(used) == ref
assert buf[len(used) :] == bytes(10) # tail untouched
out = bytearray(len(MSG) + 5)
pt = decrypt_into(out, used, KEY, NONCE, ad=AD)
assert len(pt) == len(MSG)
assert pt == MSG
def test_inplace():
buf = bytearray(MSG + bytes(16))
ct = encrypt_into(buf, buf[: len(MSG)], KEY, NONCE, ad=AD)
assert bytes(ct) == encrypt(MSG, KEY, NONCE, ad=AD)
pt = decrypt_into(buf, buf, KEY, NONCE, ad=AD)
assert pt == MSG
def test_maclen_zero():
ct = encrypt(MSG, KEY, NONCE, ad=AD, maclen=0)
assert len(ct) == len(MSG) # no tag appended
assert decrypt(ct, KEY, NONCE, maclen=0) == MSG
box = encrypt(MSG, KEY, maclen=0)
assert len(box) == NONCE_LEN + len(MSG)
assert decrypt(box, KEY, maclen=0) == MSG
@pytest.mark.parametrize(
"fn",
[
lambda: encrypt_into(bytearray(1), MSG, KEY, NONCE, ad=AD),
lambda: decrypt_into(
bytearray(1), encrypt(MSG, KEY, NONCE, ad=AD), KEY, NONCE, ad=AD
),
],
)
def test_too_small_output_buffer_raises_type_error(fn):
# a program bug, not bad wire data
with pytest.raises(TypeError):
fn()
@pytest.mark.parametrize(
"fn",
[
lambda: decrypt(b"short", KEY, NONCE, ad=AD),
lambda: decrypt(b"short", KEY, ad=AD),
],
)
def test_short_ciphertext_raises_value_error(fn):
# bad wire data, not a program bug
with pytest.raises(ValueError):
fn()
def test_tampering_raises_value_error():
good = bytes(encrypt(MSG, KEY, NONCE, ad=AD))
body, tag = good[:-16], good[-16:]
cases = [
(bytes([body[0] ^ 1]) + body[1:] + tag, AD), # tampered ciphertext
(good, AD + b"x"), # wrong ad
(body + bytes([tag[0] ^ 1]) + bytes(tag[1:]), AD), # wrong tag
]
for ct, ad in cases:
with pytest.raises(ValueError):
decrypt(ct, KEY, NONCE, ad=ad)
def test_boxes():
box1, box2 = encrypt(MSG, KEY, ad=AD), encrypt(MSG, KEY, ad=AD)
assert box1 != box2 # fresh random nonce each time
assert len(box1) == NONCE_LEN + len(MSG) + 16
assert decrypt(box1, KEY, ad=AD) == MSG
buf = bytearray(len(box1))
encrypt_into(buf, MSG, KEY, ad=AD)
out = bytearray(len(MSG))
decrypt_into(out, buf, KEY, ad=AD)
assert bytes(out) == MSG
tampered = bytearray(box1)
tampered[-1] ^= 1
with pytest.raises(ValueError):
decrypt(bytes(tampered), KEY, ad=AD)
def test_decrypt_into_verifies_before_writing():
ct = bytearray(encrypt(MSG, KEY, NONCE, ad=AD))
ct[-1] ^= 1 # bad tag
out = bytearray(b"\xaa" * len(MSG))
with pytest.raises(ValueError):
decrypt_into(out, ct, KEY, NONCE, ad=AD)
assert out == bytearray(b"\xaa" * len(MSG)) # untouched
def test_channel_in_order():
tx, rx = Sender(KEY), Receiver(KEY)
for i in range(3):
ct = tx.encrypt(f"msg {i}".encode(), ad=AD)
assert rx.decrypt(ct, ad=AD) == f"msg {i}".encode()
def test_channel_into():
tx, rx = Sender(KEY), Receiver(KEY)
buf = bytearray(len(MSG) + 16)
ct = tx.encrypt_into(buf, MSG, ad=AD)
out = bytearray(len(MSG))
assert rx.decrypt_into(out, ct, ad=AD) == MSG
def test_channel_desync_rejected():
tx = Sender(KEY)
for _ in range(3):
tx.encrypt(b"x")
with pytest.raises(ValueError):
Receiver(KEY).decrypt(tx.encrypt(b"out of order")) # nonce 0 vs 3
def test_random_reseeds_on_counter_exhaustion():
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
import quantumpipe.chab3 as m
exhausted = Cipher(
ChaCha20(bytes(32), (0xFFFFFFFF).to_bytes(4, "little") + bytes(12)),
mode=None,
).encryptor()
m._random.enc = exhausted
out = m.random(128) # needs 2 blocks; the planted stream has 1 left
assert len(out) == 128
assert m._random.enc is not exhausted
buf = bytearray(128)
m._random.enc = exhausted
m.random(buf)
assert m._random.enc is not exhausted
assert buf != bytes(128)
del m._random.enc # don't leak the fixture into other tests
+34
View File
@@ -0,0 +1,34 @@
import hashlib
import pytest
from quantumpipe.kdf import kdf
def test_matches_reference_construction():
items = [b"info", b"\x01\x02", b""]
h = hashlib.sha512(len(items).to_bytes(8, "little"))
for it in items:
h.update(len(it).to_bytes(8, "little"))
h.update(it)
assert kdf(*items) == h.digest()[:32]
def test_str_args_are_encoded():
assert kdf("info", b"data") == kdf(b"info", b"data")
# length counted after encoding: "é" is 2 bytes
assert kdf("é") == kdf("é".encode())
def test_length_framing_prevents_concat_ambiguity():
assert kdf("i", b"ab", b"c") != kdf("i", b"a", b"bc")
assert kdf("i", b"") != kdf("i")
def test_length_limits():
assert len(kdf("x", length=64)) == 64
assert len(kdf("x", length=1)) == 1
with pytest.raises(ValueError):
kdf("x", length=65)
with pytest.raises(ValueError):
kdf("x", length=0)
+792
View File
@@ -0,0 +1,792 @@
import secrets
import pytest
from quantumpipe.chab3 import encrypt
from quantumpipe.exceptions import ProtocolError
from quantumpipe.handshake import (
_MSG1_REGISTER,
_decapsulate,
_encapsulate,
_fresh_ephemeral,
_public_from_idroot,
)
from quantumpipe.kdf import kdf
from quantumpipe.sansio import (
_CREDENTIAL,
Established,
Message,
Session,
credential_id,
generate_registration_secret,
)
def make_sessions(cred_a, cred_b=None, ad=b"") -> tuple[Session, Session]:
"""A Session pair over fresh key stores; cred_b defaults to cred_a."""
a = Session({"x": cred_a}, "x", ad=ad)
b = Session({"x": cred_a if cred_b is None else cred_b}, ad=ad)
return a, b
def run_handshake(
a: Session, b: Session, answer=True
) -> tuple[Established, Established]:
(m1,) = a()
(m2,) = b(m1)
out = a(m2)
if isinstance(out[-1], bytes): # registration: two gated tail messages
(m3,) = out
(vb,) = b(m3) # the responder gates first
vb.answer = answer
(m4,) = b(vb) # his answer releases msg4
(va,) = a(m4)
assert va.sas == vb.sas
va.answer = answer
m5, est_a = a(va) # the initiator's answer finishes her side
(est_b,) = b(m5) # and msg5 finishes the responder's
assert est_a.sas == est_b.sas == va.sas
assert est_a.verified == est_b.verified == (answer is True)
else: # verified credential: no comparison, established right away
m3, est_a = out
(est_b,) = b(m3)
assert est_a.sas == est_b.sas # the stored digest
assert est_a.verified and est_b.verified
assert isinstance(est_a, Established)
assert a.state == b.state == "established"
# both ends derived matching channels: traffic roundtrips both ways
(w,) = a(Message(b"ping", 1))
assert b(w) == [Message(b"ping", 1)]
(w,) = b(Message(b"pong", 1))
assert a(w) == [Message(b"pong", 1)]
return est_a, est_b
@pytest.fixture
def psk():
# both parties simply store the registration secret itself
return generate_registration_secret()
@pytest.fixture
def registered(psk):
a, b = make_sessions(psk)
run_handshake(a, b)
return a.credential, b.credential
def test_registration_credential(psk):
a, b = make_sessions(psk)
run_handshake(a, b)
blob_a, blob_b = a.credential, b.credential
assert len(blob_a) == len(blob_b) == _CREDENTIAL.size
id_a, shared_a, pub_b, sas_a, vf_a = _CREDENTIAL.unpack(blob_a)
id_b, shared_b, pub_a, sas_b, vf_b = _CREDENTIAL.unpack(blob_b)
assert id_a != id_b # fresh random identity roots per party
assert shared_a == shared_b # same rotated shared key
assert pub_a == _public_from_idroot(id_a) # peer pubs match peer idroots
assert pub_b == _public_from_idroot(id_b)
assert sas_a == sas_b # the stored comparison digest
assert vf_a and vf_b # confirmed by run_handshake's answers
def test_key_change_is_atomic(psk):
# the composite replaces the registration secret only at the
# key-change points -- the initiator's swap at her answer, the
# responder's when msg5 completes; until then the registration
# secret keeps serving new connections
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
assert a._store["x"] == psk
(vb,) = b(m3)
assert b._store["x"] == psk
(m4,) = b(vb) # answered (default None), but bob's swap waits for msg5
assert b._store["x"] == psk
(va,) = a(m4)
m5, _est_a = a(va) # alice's swap happens at her answer
assert len(a._store["x"]) == _CREDENTIAL.size
assert b._store["x"] == psk # bob's still pending
b(m5) # msg5 completes bob's side: swap
assert len(b._store["x"]) == _CREDENTIAL.size
def test_unanswered_registration_retries(psk):
# a connection dropped mid-registration leaves the registration
# secret live; the next connection registers afresh
store_a, store_b = {"x": psk}, {"x": psk}
a, b = Session(store_a, "x"), Session(store_b)
(m1,) = a()
(m2,) = b(m1)
a(m2) # parked awaiting msg4, never answered
assert store_a["x"] == psk and store_b["x"] == psk
run_handshake(Session(store_a, "x"), Session(store_b)) # simply retried
def test_traffic_roundtrip(psk):
a, b = make_sessions(psk)
run_handshake(a, b)
(w,) = a(Message(b"one", 1))
assert b(w) == [Message(b"one", 1)]
(w,) = a(Message(b"", 1))
assert b(w) == [Message(b"", 1)] # empty payloads work
def test_user_kinds_pass_through(psk):
# the kind is opaque to the state machine: any nonzero value roundtrips
a, b = make_sessions(psk)
run_handshake(a, b)
for kind in (1, 2, 7, 255):
(w,) = a(Message(b"payload", kind))
assert b(w) == [Message(b"payload", kind)]
def test_protocol_kind_reserved(psk):
# kind 0 is reserved for in-band protocol messages: rejected both ways
a, b = make_sessions(psk)
run_handshake(a, b)
with pytest.raises(ProtocolError, match="reserved"):
a(Message(b"x", 0))
w = bytes(a._tx.encrypt(bytes([0]) + b"x")) # inbound kind 0
with pytest.raises(ProtocolError, match="reserved"):
b(w)
with pytest.raises(ProtocolError):
a(Message(b"x", 300)) # does not fit the kind byte
def test_traffic_tamper_rejected(psk):
# any tampering with a traffic frame fails the open; failed opens
# consume no nonce, so the untampered frame still opens afterwards
a, b = make_sessions(psk)
run_handshake(a, b)
cred = b.credential
(w,) = a(Message(b"ping", 1))
w = bytearray(w)
orig = bytes(w)
w[-1] ^= 0x01
with pytest.raises(ProtocolError):
b(bytes(w))
w[0] ^= 0x01 # a different spot: still a tag failure
with pytest.raises(ProtocolError):
b(bytes(w))
assert b.credential == cred # an AEAD failure burns nothing
assert b(orig) == [Message(b"ping", 1)] # no nonce consumed, still in sync
def test_traffic_in_order(psk):
# the channel is sequential: out-of-order frames fail without
# consuming a nonce
a, b = make_sessions(psk)
run_handshake(a, b)
(w1,) = a(Message(b"one", 1))
(w2,) = a(Message(b"two", 1))
with pytest.raises(ProtocolError):
b(w2) # out of order
assert b(w1) == [Message(b"one", 1)]
assert b(w2) == [Message(b"two", 1)]
def test_traffic_before_establishment_rejected(psk):
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(_m3,) = a(m2) # initiator parked awaiting msg4
with pytest.raises(ProtocolError, match="no traffic"):
a(Message(b"early", 1))
with pytest.raises(ProtocolError, match="no traffic"):
b(Message(b"early", 1)) # responder still awaiting msg3
def test_resume_does_not_rotate(registered):
blob_a, blob_b = registered
a, b = make_sessions(blob_a, blob_b)
run_handshake(a, b)
assert a.credential == blob_a # no rotation in ordinary operation
assert b.credential == blob_b
def test_resume_fresh_session_keys(registered):
blob_a, blob_b = registered
a1, b1 = make_sessions(blob_a, blob_b)
run_handshake(a1, b1)
(ct1,) = a1(Message(b"same plaintext", 1))
a2, b2 = make_sessions(blob_a, blob_b)
run_handshake(a2, b2)
(ct2,) = a2(Message(b"same plaintext", 1))
assert ct2 != ct1 # fresh ephemeral keys
def test_role_reversal(registered):
a, b = make_sessions(registered[1], registered[0]) # either may initiate
run_handshake(a, b)
def test_state_strings(psk):
a, b = make_sessions(psk)
assert a.state == "reg:hello"
assert b.state == "hello" # mode unknown until msg1 selects a key
(m1,) = a()
assert a.state == "reg:msg2"
(m2,) = b(m1)
assert b.state == "reg:msg3"
(m3,) = a(m2)
assert a.state == "reg:msg4" # msg3 out, awaiting bob's challenge
(vb,) = b(m3)
assert b.state == "reg:verify" # the responder gates first
(m4,) = b(vb) # default answer: proceed unverified
assert b.state == "reg:msg5"
(va,) = a(m4)
assert a.state == "reg:verify"
(m5, _est_a) = a(va)
assert a.state == "established"
(_est_b,) = b(m5)
assert b.state == "established"
def test_established_gated_on_answer(psk):
# the comparison answers gate the tail of the exchange: the
# responder's answer releases msg4, the initiator's releases msg5
# and finishes her side; msg5 finishes the responder's
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
assert a.state == "reg:msg4" and b.state == "reg:verify"
with pytest.raises(ProtocolError):
_ = b.verified # handshake not complete
vb.answer = True
(m4,) = b(vb)
assert b.state == "reg:msg5" # answered, but not established
with pytest.raises(ProtocolError):
_ = b.verified # still not complete
(va,) = a(m4)
va.answer = True
m5, est_a = a(va)
assert est_a.verified and est_a.sas == va.sas
(est_b,) = b(m5)
assert est_b.verified and est_b.sas == va.sas
def test_verification_reject(psk):
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
(m4,) = b(vb) # responder proceeds (default answer)
(va,) = a(m4)
va.answer = False
with pytest.raises(ProtocolError, match="rejected"):
a(va)
assert a.state == "failed"
assert a._store == {} # rejected: the credential is deleted; no msg5
with pytest.raises(ProtocolError):
a()
def test_verification_reject_responder(psk):
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
vb.answer = False
with pytest.raises(ProtocolError, match="rejected"):
b(vb)
assert b.state == "failed"
assert b._store == {} # rejected: the credential is deleted; no msg4
def test_verification_not_required(psk):
# answer=None (the default) skips the comparison and continues,
# flagged unverified
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
(m4,) = b(vb)
(va,) = a(m4)
m5, est_a = a(va)
(est_b,) = b(m5)
assert isinstance(est_a, Established) and isinstance(est_b, Established)
assert not est_a.verified and not est_b.verified
assert not a.verified and not b.verified
assert est_a.sas == est_b.sas # the SAS is still exposed
assert a.state == b.state == "established"
assert _CREDENTIAL.unpack(a._store["x"])[4] is False # stored unverified
def test_wrong_calls_rejected(psk):
a, b = make_sessions(psk)
with pytest.raises(ProtocolError):
_ = a.verification # too early
with pytest.raises(ProtocolError):
_ = a.verified # not complete
(m1,) = a()
with pytest.raises(ProtocolError):
a() # local advance not possible while expecting msg2
(m2,) = b(m1)
with pytest.raises(ProtocolError):
b() # msg3 expected, local advance not possible
(m3,) = a(m2)
with pytest.raises(ProtocolError):
a() # msg4 expected, local advance not possible
with pytest.raises(ProtocolError):
a(b"junk") # msg4 expected, junk does not open
(vb,) = b(m3)
with pytest.raises(ProtocolError):
b(b"junk") # verify takes only the comparison answer
(m4,) = b(vb)
(_v,) = a(m4)
with pytest.raises(ProtocolError):
a(b"junk") # verify takes only the comparison answer
with pytest.raises(ProtocolError, match="key_id"):
Session({})() # initiation requires a key_id
def test_session_ad(psk, registered):
ad = b"myapp v2"
for cred_a, cred_b in ((psk, psk), registered):
a, b = make_sessions(cred_a, cred_b, ad=ad)
run_handshake(a, b)
def test_mismatched_ad_rejected(psk, registered):
for cred_a, cred_b in ((psk, psk), registered):
a = Session({"x": cred_a}, "x", ad=b"v1")
b = Session({"x": cred_b}, ad=b"v2")
(m1,) = a()
with pytest.raises(ProtocolError):
b(m1)
assert b._store["x"] == cred_b # an AEAD failure burns nothing
def test_tampered_msg1_rejected(psk, registered):
for cred_a, cred_b in ((psk, psk), registered):
a, b = make_sessions(cred_a, cred_b)
(m1,) = a()
m1 = bytearray(m1)
m1[-1] ^= 0x01
with pytest.raises(ProtocolError):
b(bytes(m1))
assert b._store["x"] == cred_b # an AEAD failure burns nothing
def test_tampered_msg2_rejected(psk, registered):
for cred_a, cred_b in ((psk, psk), registered):
a, b = make_sessions(cred_a, cred_b)
(m1,) = a()
m2 = bytearray(b(m1)[0])
m2[-1] ^= 0x01
with pytest.raises(ProtocolError):
a(bytes(m2))
assert a._store["x"] == cred_a # an AEAD failure burns nothing
def test_tampered_msg3_rejected(psk, registered):
for cred_a, cred_b in ((psk, psk), registered):
a, b = make_sessions(cred_a, cred_b)
(m1,) = a()
m2 = b(m1)[0]
m3 = bytearray(a(m2)[0]) # msg3 comes first in both flows
m3[-1] ^= 0x01
with pytest.raises(ProtocolError):
b(bytes(m3))
assert b._store["x"] == cred_b # an AEAD failure burns nothing
def test_tampered_msg4_rejected(psk):
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
m4 = bytearray(b(vb)[0])
m4[-1] ^= 0x01
with pytest.raises(ProtocolError):
a(bytes(m4))
assert a._store["x"] == psk # an AEAD failure burns nothing
def test_tampered_msg5_rejected(psk):
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
(m4,) = b(vb)
(va,) = a(m4)
m5 = bytearray(a(va)[0]) # registration's last message
m5[-1] ^= 0x01
with pytest.raises(ProtocolError):
b(bytes(m5))
assert b._store["x"] == psk # an AEAD failure burns nothing
def test_mismatched_shared_rejected(psk, registered):
# bob still holds the bare registration secret, alice the rotated
# composite: the shared keys differ, so her msg1 box does not open
a = Session({"x": registered[0]}, "x")
b = Session({"x": psk})
(m1,) = a()
with pytest.raises(ProtocolError):
b(m1)
assert b._store["x"] == psk # no burn
def test_forged_idroot_rejected(registered):
# resume composite with a wrong idroot: decapsulation yields garbage,
# so bob's tag fails to verify -- invalid data inside a valid box,
# and the forged credential burns
blob = registered[0]
bad = _CREDENTIAL.pack(secrets.token_bytes(32), *_CREDENTIAL.unpack(blob)[1:])
a, b = make_sessions(bad, registered[1])
(m1,) = a()
(m2,) = b(m1)
with pytest.raises(ProtocolError, match="burned"):
a(m2)
assert a._store == {} # burned
assert b._store["x"] == registered[1] # bob's side untouched
def test_verified_resumption_is_silent(registered):
# a verified credential authenticates via the pinned statics: no
# comparison is asked
a, b = make_sessions(registered[0], registered[1])
(m1,) = a()
(m2,) = b(m1) # no Verification emitted
assert b.state == "auth:msg3"
with pytest.raises(ProtocolError):
_ = b.verification # nothing to verify
m3, est_a = a(m2)
assert isinstance(est_a, Established)
assert est_a.verified and est_a.sas # pinned statics, stored digest
assert a.state == "established"
(est_b,) = b(m3)
assert isinstance(est_b, Established)
assert est_b.verified and est_b.sas == est_a.sas
def test_unverified_resumption_is_silent(psk):
# registration skipped: the stored composite is unverified, but the
# resumption flow never prompts -- the stored digest rides on
# Established and the parties confirm or reject whenever they wish
a, b = make_sessions(psk)
run_handshake(a, b, answer=None)
cred_a, cred_b = a.credential, b.credential
assert _CREDENTIAL.unpack(cred_a)[4] is False
a2, b2 = make_sessions(cred_a, cred_b)
(m1,) = a2()
(m2,) = b2(m1) # no Verification emitted on the resumption flow
m3, est_a = a2(m2)
(est_b,) = b2(m3)
assert a2.state == b2.state == "established"
assert not est_a.verified and not est_b.verified
# the stored digest is still exposed for an optional comparison
assert est_a.sas == est_b.sas == a2.verification.sas
a2.confirm(True) # compared out of band: flag it verified
b2.confirm(True)
assert a2.verified and b2.verified
assert _CREDENTIAL.unpack(a2._store["x"])[4] is True # flag flipped
a3, b3 = make_sessions(a2._store["x"], b2._store["x"])
run_handshake(a3, b3) # verified: silent as before
def test_resumption_reject_deletes(psk):
a, b = make_sessions(psk)
run_handshake(a, b, answer=None)
a2, b2 = make_sessions(a.credential, b.credential)
(m1,) = a2()
(m2,) = b2(m1)
m3, _est_a = a2(m2)
b2(m3)
a2.confirm(False) # rejected even on the resumption flow: gone
assert a2._store == {}
def test_responder_scans_key_store(psk):
# a server holds credentials for many callers; msg1 picks the one
# that opens it, and only that entry is touched
other1, other2 = generate_registration_secret(), generate_registration_secret()
a = Session({"alice": psk}, "alice")
b = Session({"alice": psk, "bob": other1, "carol": other2})
run_handshake(a, b)
assert len(a._store["alice"]) == _CREDENTIAL.size
assert len(b._store["alice"]) == _CREDENTIAL.size # registered
assert b._store["bob"] == other1 # untouched
assert b._store["carol"] == other2
# same rotated key on both sides
assert (
_CREDENTIAL.unpack(a._store["alice"])[1]
== _CREDENTIAL.unpack(b._store["alice"])[1]
)
def test_unknown_caller_no_burn(psk):
stranger = Session({"x": generate_registration_secret()}, "x")
b = Session({"alice": psk})
(m1,) = stranger()
with pytest.raises(ProtocolError):
b(m1) # no credential opens the box
assert b._store == {"alice": psk} # nothing burned
def test_parallel_registration_loser_fails(psk):
# two registrations race on one registration key: the first exchange
# to complete msg5 swaps the slot -- the loser's finish finds the
# registration key gone and fails instead of overwriting
srv = {"alice": psk}
x, y = Session({"s": psk}, "s"), Session({"s": psk}, "s")
sx, sy = Session(srv), Session(srv) # two responder sessions, one store
(m1x,) = x()
(m1y,) = y()
(m2x,) = sx(m1x)
(m2y,) = sy(m1y) # both in flight on the same key
(m3x,) = x(m2x)
(m3y,) = y(m2y)
(vx,) = sx(m3x)
(vy,) = sy(m3y) # both responders parked; the key is still live
assert srv["alice"] == psk
(m4x,) = sx(vx) # default answers release the msg4s
(m4y,) = sy(vy)
(vx2,) = x(m4x)
(vy2,) = y(m4y)
m5x, _ex = x(vx2)
(est_x,) = sx(m5x) # X completes first: wins the swap
assert isinstance(est_x, Established)
assert len(srv["alice"]) == _CREDENTIAL.size
m5y, _ey = y(vy2)
with pytest.raises(ProtocolError, match="spent"):
sy(m5y) # Y finds the registration key gone
assert sy.state == "failed"
assert len(srv["alice"]) == _CREDENTIAL.size # the winner is intact
def test_parallel_registration_same_client_store(psk):
# the same arbitration on the initiator's store: the second session
# to answer loses
cli = {"s": psk}
x, y = Session(cli, "s"), Session(cli, "s")
b1, b2 = Session({"s": psk}), Session({"s": psk})
(m1x,) = x()
(m1y,) = y()
(m2x,) = b1(m1x)
(m2y,) = b2(m1y)
(m3x,) = x(m2x)
(m3y,) = y(m2y)
(v1,) = b1(m3x)
(v2,) = b2(m3y)
(m4x,) = b1(v1) # default answers release the msg4s
(m4y,) = b2(v2)
(vx,) = x(m4x)
(vy,) = y(m4y) # both parked; the registration key is still live
assert cli["s"] == psk
vx.answer = None # even a skip swaps: X wins
_m5x, est_x = x(vx)
assert isinstance(est_x, Established)
assert len(cli["s"]) == _CREDENTIAL.size
vy.answer = None
with pytest.raises(ProtocolError, match="spent"):
y(vy)
assert len(cli["s"]) == _CREDENTIAL.size # not overwritten
def test_loser_burn_spares_winner(psk):
# the loser's content failure must not burn the winner's freshly
# swapped credential
srv = {"alice": psk}
x, y = Session({"s": psk}, "s"), Session({"s": psk}, "s")
sx, sy = Session(srv), Session(srv)
(m1x,) = x()
(m1y,) = y()
(m2x,) = sx(m1x)
(m2y,) = sy(m1y)
(m3x,) = x(m2x)
(vx,) = sx(m3x)
(m4x,) = sx(vx)
(vx2,) = x(m4x)
m5x, _ex = x(vx2)
sx(m5x) # X completes first: wins the swap
y._exchange._reveal = secrets.token_bytes(32) # Y lies about its reveal
(m3y,) = y(m2y)
with pytest.raises(ProtocolError, match="commitment"):
sy(m3y)
assert len(srv["alice"]) == _CREDENTIAL.size # winner intact, no burn
def test_flag_flip_tolerates_parallel_confirm(psk):
# two resumptions of the same credential confirm it concurrently;
# the second flag flip must recognize it as the same credential
# (only the flag byte changed underneath it)
cli, srv = {"s": psk}, {"s": psk}
run_handshake(Session(cli, "s"), Session(srv), answer=None)
r1, r2 = Session(cli, "s"), Session(cli, "s") # same store, two connections
s1, s2 = Session(srv), Session(srv)
(m1_1,) = r1()
(m1_2,) = r2()
(m2_1,) = s1(m1_1)
(m2_2,) = s2(m1_2)
m3_1, _e1 = r1(m2_1)
m3_2, _e2 = r2(m2_2)
s1(m3_1)
s2(m3_2)
r1.confirm(True) # flips the slot to verified
r2.confirm(True) # slot changed under it, but same credential
assert r1.verified and r2.verified
assert _CREDENTIAL.unpack(cli["s"])[4] is True
def test_commitment_reveal_checked(psk):
# a reveal that does not open the msg1 commitment is invalid data
# inside a valid box: the credential burns
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
a._exchange._reveal = secrets.token_bytes(32) # alice lies about her reveal
(m3,) = a(m2)
with pytest.raises(ProtocolError, match="commitment"):
b(m3)
assert b._store == {} # burned
def test_mitm_digest_not_steerable(psk):
# A MITM who knows the registration secret completes independent legs
# with both parties. Bob's digest derives from the commitment the MITM
# made in msg1 -- before alice's reveal could be known -- so it cannot
# be steered toward alice's digest: the two values simply differ.
a = Session({"x": psk}, "x")
m_in = Session({"x": psk})
m_out = Session({"x": psk}, "x")
b = Session({"x": psk})
(m1,) = a()
(m2m,) = m_in(m1) # the MITM answers alice
(m3a,) = a(m2m)
(vm,) = m_in(m3a)
(m4m,) = m_in(vm)
(va,) = a(m4m) # alice's digest
(m1m,) = m_out() # and opens his own leg toward bob
(m2b,) = b(m1m)
(m3m,) = m_out(m2b)
(vb,) = b(m3m) # bob's digest
assert va.sas != vb.sas
def test_mitm_reused_reveal_detected(psk):
# A MITM who finishes a leg with alice and steals her reveal cannot
# reuse it toward bob: the sas covers the per-connection ee, which
# the responder contributes only after the commitment -- so a
# recommitted reveal still produces a different digest.
a = Session({"x": psk}, "x")
m_in = Session({"x": psk})
m_out = Session({"x": psk}, "x")
b = Session({"x": psk})
# leg alpha: the MITM completes with alice and steals her reveal
(m1,) = a()
(m2m,) = m_in(m1)
(m3a,) = a(m2m)
(vm,) = m_in(m3a)
r_a = a._exchange._reveal
# leg beta: the MITM recommits the stolen reveal toward bob
hs = m_out._exchange
hs._reveal = r_a
hs._ephemeral_root, e_pub = _fresh_ephemeral()
body = _MSG1_REGISTER.pack(e_pub, kdf("commit", e_pub, r_a))
msg1m = bytes(encrypt(body, key=kdf("reg/msg1", psk), ad=b""))
hs._chain.advance(msg1m) # startA was bypassed: advance the chain
(m2b,) = b(msg1m)
m3m = hs.respondA(m2b)
(vb,) = b(m3m) # bob's checks pass: the commitment is valid...
(m4m,) = m_in(vm)
(va,) = a(m4m)
assert va.sas != vb.sas # ...but the digests differ
def test_relayed_reveal_rejected(psk):
# A MITM relaying alice's msg1 verbatim cannot reuse her msg3 either:
# the reveal box is keyed and ad-chained to her own leg only.
a = Session({"x": psk}, "x")
m = Session({"x": psk})
b = Session({"x": psk})
(m1,) = a()
b(m1) # bob answers the relayed hello
(m2m,) = m(m1) # the MITM answers alice too
(m3,) = a(m2m) # alice reveals r -- on the MITM's leg only
with pytest.raises(ProtocolError):
b(m3)
assert b._store["x"] == psk # an AEAD failure burns nothing
def test_low_order_x25519_rejected():
# non-contributory public keys are rejected outright in the hybrid
# encapsulation and decapsulation paths
idroot = secrets.token_bytes(32)
with pytest.raises(ValueError):
_encapsulate(b"\x00" * 1184 + b"\x00" * 32)
with pytest.raises(ValueError):
_decapsulate(idroot, b"\x00" * 1088 + b"\x00" * 32)
def test_foreign_mlkem_static_rejected_at_msg4(psk):
# alice presents a static whose ML-KEM half she does not hold (foreign
# mlkem half, her own x25519 half): she cannot decapsulate bob's ct_se
# challenge, so bob's confirm tag does not match her root and she burns
a, b = make_sessions(psk)
real = a._exchange._static_pub
foreign = _public_from_idroot(secrets.token_bytes(32))
a._exchange._static_pub = foreign[:1184] + real[1184:]
(m1,) = a()
(m2,) = b(m1)
(m3,) = a(m2)
(vb,) = b(m3)
(m4,) = b(vb)
with pytest.raises(ProtocolError, match="responder tag"):
a(m4)
assert a._store == {} # burned
def test_foreign_mlkem_static_rejected_responder(psk):
# the symmetric check on the responder: bob cannot answer alice's
# ct_es challenge for the static he presented -- his decapsulation
# key does not match it, the inner box keyed with es does not open
# for him, and he burns
a, b = make_sessions(psk)
(m1,) = a()
(m2,) = b(m1)
b._exchange._idroot = secrets.token_bytes(32) # not the presented static's root
(m3,) = a(m2)
with pytest.raises(ProtocolError, match="burned"):
b(m3)
assert b._store == {} # burned
def test_credential_id(psk):
# 16 bytes, recomputable from the stored record
assert len(credential_id(psk)) == 16
assert credential_id(psk) == credential_id(bytes(psk))
a, b = make_sessions(psk)
run_handshake(a, b, answer=None)
blob = a.credential
# stable across the verified flag (metadata may change)
flagged = _CREDENTIAL.pack(*_CREDENTIAL.unpack(blob)[:4], True)
assert credential_id(blob) == credential_id(flagged)
# tied to the credential: a different record hashes differently
assert credential_id(blob) != credential_id(psk) # rotation rotates it
assert credential_id(blob) != credential_id(b.credential) # per-record
forged = _CREDENTIAL.pack(secrets.token_bytes(32), *_CREDENTIAL.unpack(blob)[1:])
assert credential_id(forged) != credential_id(blob)
def test_confirm_guards(psk):
a, b = make_sessions(psk)
with pytest.raises(ProtocolError, match="nothing to confirm"):
a.confirm(True) # handshake not complete
run_handshake(a, b) # verified by the registration answer already
with pytest.raises(ProtocolError, match="nothing to confirm"):
a.confirm(False)
with pytest.raises(ProtocolError, match="nothing to verify"):
_ = a.verification # verified: no comparison value anymore
+101
View File
@@ -0,0 +1,101 @@
"""Tests for the SecureBuffer mlock'd, no-core-dump, wipe-on-close buffer.
CPython-only: we rely on reference counting to free memoryviews as soon
as they go out of scope (a `del` or function return), so no release()
calls or view context managers are needed. Live exported views would
make mmap.close() fail with BufferError, so view names are del'd before
the owning SecureBuffer is closed.
"""
import pytest
from quantumpipe.chab3 import decrypt, decrypt_into, encrypt, encrypt_into
from quantumpipe.securebuf import SecureBuffer, wipe
def test_buffer_protocol_read_write():
with SecureBuffer(32) as sb:
assert len(sb) == 32
mv = memoryview(sb)
assert len(mv) == 32
assert not mv.readonly
mv[:4] = b"abcd"
assert mv[:4] == b"abcd"
del mv
assert sb.closed
def test_not_convertible_to_bytes():
with SecureBuffer(16) as sb, pytest.raises(TypeError, match="cannot be converted"):
bytes(sb)
def test_wipe_utility_fills_ff():
sb = SecureBuffer(16)
sb[:] = b"A" * 16
wipe(sb)
mv = memoryview(sb)
assert mv.tobytes() == b"\xff" * 16
del mv
sb.close()
def test_close_is_idempotent():
sb = SecureBuffer(16)
sb.close()
assert sb.closed
sb.close() # second close is a no-op
assert sb.closed
def test_context_manager_closes():
with SecureBuffer(16) as sb:
pass
assert sb.closed
def test_context_manager_closes_on_exception():
with pytest.raises(RuntimeError), SecureBuffer(16) as sb:
raise RuntimeError("boom")
assert sb.closed
def test_del_closes():
import gc
sb = SecureBuffer(16)
ref = sb._mmap
del sb
gc.collect()
assert ref.closed
def test_roundtrip_through_chab3():
"""SecureBuffer works as key input and as an `into` target."""
with SecureBuffer(32) as key, SecureBuffer(64) as box, SecureBuffer(16) as pt:
key[:] = bytes(range(32))
box_view = encrypt_into(box, b"hello", key)
pt_view = decrypt_into(pt, box_view, key)
assert pt_view == b"hello"
del box_view, pt_view
def test_key_material_needs_no_bytes_copy():
"""chab3 accepts the SecureBuffer directly -- no bytes() conversion."""
with SecureBuffer(32) as key:
key[:] = b"\x07" * 32
ct = encrypt(b"secret", key)
assert decrypt(ct, key) == b"secret"
def test_getitem_returns_memoryview():
with SecureBuffer(16) as sb:
sb[:4][:] = b"abcd"
view = sb[2:8]
assert isinstance(view, memoryview)
assert view.tobytes() == b"cd\x00\x00\x00\x00"
view[:2] = b"XY"
xy = sb[2:4]
assert xy.tobytes() == b"XY"
assert sb[0] == ord("a")
del view, xy
+292
View File
@@ -0,0 +1,292 @@
import asyncio
import pytest
from websockets import connect as ws_connect
from websockets import serve
from websockets.exceptions import ConnectionClosed
from quantumpipe.exceptions import ProtocolError
from quantumpipe.sansio import (
_CREDENTIAL,
Message,
Verification,
generate_registration_secret,
)
from quantumpipe.ws import TEXT, SecSocket
AD = b"test app"
class FakeWS:
"""websockets-library style endpoint (send/recv) over memory queues."""
def __init__(self, inbox, outbox):
self._inbox = inbox
self._outbox = outbox
async def send(self, data):
self._outbox.put_nowait(data)
async def recv(self):
data = await self._inbox.get()
if data is None: # the peer closed
raise ConnectionClosed(None, None)
return data
async def close(self):
self._outbox.put_nowait(None) # wake the peer like a real close
class FakeStarletteWS(FakeWS):
"""FastAPI/Starlette style endpoint (accept/send_bytes/receive_bytes)."""
accepted = False
async def accept(self):
self.accepted = True
async def send_bytes(self, data):
self._outbox.put_nowait(data)
async def receive_bytes(self):
return await self.recv()
def ws_pair(starlette=False):
"""A connected pair of in-memory WebSocket endpoints (srv, cli)."""
to_srv, to_cli = asyncio.Queue(), asyncio.Queue()
cls = FakeStarletteWS if starlette else FakeWS
return cls(to_srv, to_cli), cls(to_cli, to_srv)
async def socket_pair(
cred_srv, cred_cli, verify_srv=None, verify_cli=None, ad=AD, starlette=False
):
"""Two SecSockets handshake over a fake ws pair; returns (srv, cli)."""
ws_srv, ws_cli = ws_pair(starlette)
srv = SecSocket(
ws_srv,
{"x": cred_srv},
ad=ad,
handlers={Verification: verify_srv} if verify_srv else None,
)
cli = SecSocket(
ws_cli,
{"x": cred_cli},
"x",
ad=ad,
handlers={Verification: verify_cli} if verify_cli else None,
)
await asyncio.gather(srv.accept(), cli.connect())
return srv, cli
async def test_roundtrip():
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
await cli.send(b"ping")
assert await srv.receive() == b"ping"
await srv.send(b"pong")
assert await cli.receive() == b"pong"
async def test_send_picks_frame_type():
# str goes as a TEXT frame and is delivered decoded; bytes are opaque
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
await cli.send("hello")
assert await srv.receive() == "hello"
await srv.send(b"\x00\xff")
assert await cli.receive() == b"\x00\xff"
await cli.send("")
assert await srv.receive() == "" # empty text frame
await cli.send(b"")
assert await srv.receive() == b"" # empty binary frame
async def test_message_passthrough():
# applications can use their own kinds (protobuf etc.): a Message
# passes through the wrapper untouched
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
await cli.send(Message(b"\x89fake-protobuf", 7))
assert await srv.receive() == Message(b"\x89fake-protobuf", 7)
async def test_starlette_flavor():
# the FastAPI/Starlette duck-type: accept + send_bytes/receive_bytes
psk = generate_registration_secret()
ws_srv, ws_cli = ws_pair(starlette=True)
srv = SecSocket(ws_srv, {"x": psk}, ad=AD)
cli = SecSocket(ws_cli, {"x": psk}, "x", ad=AD)
await asyncio.gather(srv.accept(), cli.connect())
assert ws_srv.accepted
await cli.send(b"ping")
assert await srv.receive() == b"ping"
async def test_protocol_kind_rejected():
# kind 0 is reserved for in-band protocol messages; none implemented
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
with pytest.raises(ProtocolError, match="reserved"):
await cli.send(Message(b"x", 0))
frame = bytes(cli.session._tx.encrypt(bytes([0]) + b"x"))
await cli._ws.send(frame) # bypass the wrapper
with pytest.raises(ProtocolError, match="reserved"):
await srv.receive()
async def test_empty_frame_rejected():
# a box with no plaintext at all carries no kind byte
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
await cli._ws.send(bytes(cli.session._tx.encrypt(b"")))
with pytest.raises(ProtocolError, match="empty"):
await srv.receive()
async def test_bad_utf8_text_frame_rejected():
psk = generate_registration_secret()
srv, cli = await socket_pair(psk, psk)
(frame,) = cli.session(Message(b"\xff\xff", TEXT))
await cli._ws.send(frame)
with pytest.raises(ProtocolError, match="utf-8"):
await srv.receive()
async def test_traffic_before_establishment_rejected():
psk = generate_registration_secret()
_ws_srv, ws_cli = ws_pair()
cli = SecSocket(ws_cli, {"x": psk}, "x", ad=AD)
with pytest.raises(ProtocolError, match="no traffic"):
await cli.send(b"early")
async def test_verification_callback():
psk = generate_registration_secret()
seen = []
async def skip(v: Verification): # -> None: skip the comparison
seen.append(v.sas)
srv, cli = await socket_pair(psk, psk, skip, skip)
cred_srv, cred_cli = srv.session.credential, cli.session.credential
assert len(seen) == 2 and seen[0] == seen[1]
# skipped comparison: established, but flagged unverified
assert not srv.session.verified and not cli.session.verified
assert cli.session.verification.sas == seen[1]
# the resumption flow never prompts: it is silent even while
# unverified; the stored digest is exposed and the parties confirm
# whenever they wish
seen.clear()
srv, cli = await socket_pair(cred_srv, cred_cli, skip, skip)
await cli.send(b"resumed")
assert await srv.receive() == b"resumed"
assert seen == [] # no comparison asked
assert not srv.session.verified and not cli.session.verified
assert cli.session.verification.sas == _CREDENTIAL.unpack(cred_cli)[3]
cli.session.confirm(True) # compared out of band: mark verified
srv.session.confirm(True)
assert cli.session.verified and srv.session.verified
cred_srv, cred_cli = srv.session.credential, cli.session.credential
# a verified credential resumes silently, flagged verified
srv, cli = await socket_pair(cred_srv, cred_cli, skip, skip)
await cli.send(b"again")
assert await srv.receive() == b"again"
assert seen == []
assert srv.session.verified and cli.session.verified
async def test_verification_rejected():
psk = generate_registration_secret()
async def reject(v: Verification):
return False
ws_srv, ws_cli = ws_pair()
srv_store = {"x": psk}
srv = SecSocket(ws_srv, srv_store, ad=AD)
cli_store = {"x": psk}
cli = SecSocket(ws_cli, cli_store, "x", ad=AD, handlers={Verification: reject})
srv_task = asyncio.create_task(srv.accept())
# the responder's default answer releases msg4, but the initiator's
# reject holds back msg5: her side fails, the responder never swaps
with pytest.raises(ProtocolError, match="rejected"):
await cli.connect()
assert cli.session.state == "failed"
assert cli_store == {} # rejected: the credential is deleted
with pytest.raises(ConnectionClosed):
await srv_task
assert srv.session.state == "reg:msg5" # parked: no msg5 ever came
assert srv_store == {"x": psk} # the registration key is intact
async def test_parallel_registration_race():
"""Two registrations racing on one registration key: the swap at the
comparison answer arbitrates -- the first answer (the app default
skip, right after msg3) wins the store slot, the loser's server
session fails with "registration key already spent". Both clients
still establish (the loser's composite is orphaned server-side)."""
psk = generate_registration_secret()
srv_store = {"x": psk}
outcomes = []
async def server_side(ws):
sock = SecSocket(ws, srv_store, ad=AD)
try:
await sock.accept()
except Exception as e: # noqa: BLE001 -- recorded and asserted below
outcomes.append(e)
else:
outcomes.append("established")
async def client_side():
ws_srv, ws_cli = ws_pair()
srv_task = asyncio.create_task(server_side(ws_srv))
store = {"x": psk}
sock = SecSocket(ws_cli, store, "x", ad=AD)
await sock.connect()
assert sock.session.state == "established"
return srv_task, store
(t1, s1), (t2, s2) = await asyncio.gather(client_side(), client_side())
await asyncio.gather(t1, t2)
# exactly one winner server-side; the loser is rejected at the swap
fails = [o for o in outcomes if isinstance(o, ProtocolError)]
assert outcomes.count("established") == 1
assert len(fails) == 1 and "registration key already spent" in str(fails[0])
# the winner's composite replaced the registration key; the loser's
# burn/delete machinery never touched the slot again
assert len(srv_store) == 1 and len(srv_store["x"]) == _CREDENTIAL.size
# both clients rotated their own stores and established
assert all(len(s["x"]) == _CREDENTIAL.size for s in (s1, s2))
async def test_real_websockets_roundtrip():
"""Integration over a real localhost websockets server/client."""
psk = generate_registration_secret()
srv_sock = None
async def handler(ws):
nonlocal srv_sock
srv_sock = SecSocket(ws, {"x": psk}, ad=AD)
await srv_sock.accept()
await ws.wait_closed()
server = await serve(handler, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
ws = await ws_connect(f"ws://127.0.0.1:{port}")
cli = SecSocket(ws, {"x": psk}, "x", ad=AD)
await cli.connect()
while srv_sock is None or srv_sock.session.state != "established":
await asyncio.sleep(0.01)
await cli.send("ping")
assert await srv_sock.receive() == "ping"
await srv_sock.send(b"pong")
assert await cli.receive() == b"pong"
await ws.close()
server.close()