Initial commit
This commit is contained in:
commit
03a7758fa6
24 files changed
+3408
No files matched your search
+14
@@ -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/
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user