frutebox-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,660 @@
1
+ """Hybrid post-quantum transport — content is never plaintext on a wire (file 22).
2
+
3
+ THE RULE (owner, non-negotiable, universal)
4
+ For every user, free or paid: the prompt and any attached files are ALWAYS encrypted when they
5
+ cross a network, on every hop. Only ROUTING METADATA may be in the clear — model, tier, region,
6
+ latency class, job id, size hint, jurisdiction class. Nothing that reveals content.
7
+
8
+ THE THREAT: HARVEST NOW, DECRYPT LATER
9
+ An adversary can record today's traffic and decrypt it once a quantum computer exists. That is
10
+ why post-quantum key exchange has to be the default NOW rather than an upgrade later: traffic
11
+ sent today is already exposed to a machine that does not exist yet.
12
+
13
+ WHY HYBRID AND NOT PQC ALONE (22-B1)
14
+ `X25519MLKEM768` is X25519 ECDH **and** ML-KEM-768 (FIPS 203), with both shared secrets mixed
15
+ into one key. It is safe if either primitive falls: a quantum break of X25519 leaves ML-KEM, and
16
+ an implementation flaw in early ML-KEM leaves X25519. This is what Chrome, Cloudflare and AWS
17
+ deployed in 2025-26.
18
+
19
+ ML-KEM comes from `cryptography`'s OpenSSL binding, which is what `libraries.md` names
20
+ ("OpenSSL 3.5 / liboqs", and note 7 prefers OpenSSL-native PQC). No new dependency, no
21
+ home-rolled lattice code.
22
+
23
+ NO DOWNGRADE (22-B5)
24
+ A peer that cannot negotiate the hybrid suite is REFUSED. There is no classical-only fallback for
25
+ a content-bearing hop — a downgrade path is the vulnerability, because an attacker who can force
26
+ the downgrade gets the plaintext-equivalent they wanted. Suites are config-named
27
+ (`FRUTEBOX_PQC_KEM`, `FRUTEBOX_PQC_SIG`; the `STEVAI_` spellings are still read) so the
28
+ algorithm can advance, never descend.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ from dataclasses import dataclass
34
+ from typing import Optional, Tuple
35
+
36
+ from cryptography.hazmat.primitives import hashes
37
+ from cryptography.hazmat.primitives.asymmetric import mlkem, x25519
38
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
39
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
40
+ from .envvars import env as envvar
41
+
42
+ #: The negotiated suite. Config-named for crypto-agility (22-B5) — may advance, never descend.
43
+ KEM_SUITE = envvar("PQC_KEM", "X25519MLKEM768")
44
+ SIG_SUITE = envvar("PQC_SIG", "ML-DSA-65")
45
+
46
+ #: Payload AEAD. Both are 256-bit symmetric, which is already quantum-resistant: Grover only
47
+ #: halves the effective strength, leaving 128 bits.
48
+ AEAD = envvar("PQC_AEAD", "AES-256-GCM")
49
+
50
+ #: Suites we will negotiate. Anything else is refused rather than downgraded to.
51
+ #: X25519MLKEM768 is the deployed default (22-B1). The 1024 variant is the same HYBRID construction
52
+ #: with ML-KEM-1024 in place of ML-KEM-768, so it is an ADVANCE and belongs here — a floor that
53
+ #: rejected it would have been a ceiling, and 22-B5's whole point is that the suite can move up.
54
+ #: What is NOT here, and cannot be configured, is anything classical-only.
55
+ SUPPORTED_KEMS = frozenset({"X25519MLKEM768", "X25519MLKEM1024"})
56
+ SUPPORTED_AEADS = frozenset({"AES-256-GCM", "ChaCha20-Poly1305"})
57
+ #: 22-B2's signature families. ML-DSA (FIPS 204) by default, SLH-DSA (FIPS 205) as the conservative
58
+ #: hash-based alternative. A classical-only signature is not on this list and cannot be configured.
59
+ SUPPORTED_SIGS = frozenset({"ML-DSA-65", "ML-DSA-87", "SLH-DSA-SHA2-128s"})
60
+
61
+
62
+ #: KEMs and signatures that are CLASSICAL ONLY. Naming one of these is always a downgrade, whatever
63
+ #: else changes, so they are refused by name — a denylist rather than an allowlist, and that choice is
64
+ #: the whole design of the floor below.
65
+ CLASSICAL_ONLY = frozenset({
66
+ "X25519", "X448", "P-256", "P-384", "P-521", "ECDH", "ECDHE", "DH", "RSA", "RSA-OAEP",
67
+ "Ed25519", "Ed448", "ECDSA", "RSA-PSS", "RSASSA-PKCS1-v1_5",
68
+ })
69
+
70
+
71
+ def _assert_suite_is_not_a_downgrade() -> None:
72
+ """OUR OWN configured suite must not be a classical one. Checked at import.
73
+
74
+ ── THE HOLE THIS CLOSES ─────────────────────────────────────────────────────────────────────────
75
+ `negotiate` checked the PEER's offer and never checked ours. The selection line read
76
+
77
+ kem = KEM_SUITE if KEM_SUITE in offered else sorted(offered & SUPPORTED_KEMS)[0]
78
+
79
+ so with `FRUTEBOX_PQC_KEM=X25519` — classical only — against a peer offering BOTH suites, the first
80
+ clause matched and A CLASSICAL KEM WAS SELECTED for a content-bearing hop. The peer check passed,
81
+ nothing raised, and the channel was quantum-vulnerable by configuration alone.
82
+
83
+ ── WHY A DENYLIST AND NOT AN ALLOWLIST ──────────────────────────────────────────────────────────
84
+ An allowlist was the first attempt and it is wrong, because the done-criterion asks for two things
85
+ at once: the suite "swaps without code changes" AND "still refuses non-PQC". An allowlist fails the
86
+ first — adopting a genuinely new NIST suite would need this file edited, which is exactly the
87
+ inflexibility 22-B5 made these names configurable to avoid. It also broke a test that uses
88
+ placeholder names to prove which environment spelling wins, and that test was right to.
89
+
90
+ A denylist gets both: a name we have never heard of passes here (and still cannot be NEGOTIATED,
91
+ see `negotiate` — the floor that matters on the wire is the intersection with `SUPPORTED_KEMS`),
92
+ while a classical family is refused by name with certainty and no false positives.
93
+
94
+ FAILING AT IMPORT IS THE POINT for the classical case. The alternative, refusing per-connection,
95
+ means a typo takes the fleet down one confusing request at a time; this way it is one named error
96
+ before anything is served — the same choice `gateway.py` makes for the territory filter.
97
+ """
98
+ for var, value, kind in (("FRUTEBOX_PQC_KEM", KEM_SUITE, "key exchange"),
99
+ ("FRUTEBOX_PQC_SIG", SIG_SUITE, "signature")):
100
+ if value in CLASSICAL_ONLY:
101
+ raise DowngradeRefused(
102
+ f"{var} is {value!r}, which is a CLASSICAL-ONLY {kind}. 22-B5 allows this to advance, "
103
+ f"never to descend: a content-bearing hop is not served over a suite weaker than "
104
+ f"hybrid post-quantum. Refusing to start.")
105
+ if AEAD not in SUPPORTED_AEADS:
106
+ # The AEAD is different in kind: the code must actually implement it to encrypt anything, so
107
+ # there is no "unknown but fine" case and an allowlist is correct here.
108
+ raise DowngradeRefused(
109
+ f"FRUTEBOX_PQC_AEAD is {AEAD!r}, which is not one of {sorted(SUPPORTED_AEADS)}. "
110
+ f"Refusing to start rather than silently falling back to a suite nobody chose.")
111
+
112
+
113
+ _assert_suite_is_not_a_downgrade()
114
+
115
+ #: Routing metadata that may travel in the clear (22-A2). Nothing here reveals content.
116
+ PUBLIC_METADATA_KEYS = frozenset({
117
+ "job_id", "model", "tier", "region", "latency_class", "size_hint",
118
+ "jurisdiction_class", "redundant", "allowed_jurisdictions",
119
+ })
120
+
121
+ _HKDF_INFO = b"frutebox/transport/v1"
122
+
123
+
124
+ class DowngradeRefused(Exception):
125
+ """A peer offered something weaker than the hybrid PQC suite. Content-bearing hops fail
126
+ closed rather than negotiate down."""
127
+
128
+
129
+ class TransportError(Exception):
130
+ """Sealing or opening failed."""
131
+
132
+
133
+ def negotiate(peer_kems, peer_aeads=None) -> Tuple[str, str]:
134
+ """Agree a suite with a peer, or refuse (22-B5).
135
+
136
+ Deliberately not "pick the best mutually supported" — there is exactly one acceptable KEM
137
+ family, and a peer without it does not get a weaker channel, it gets no channel.
138
+ """
139
+ offered = set(peer_kems or ())
140
+ if not (offered & SUPPORTED_KEMS):
141
+ raise DowngradeRefused(
142
+ f"peer offers {sorted(offered) or ['nothing']}; this hop carries content and "
143
+ f"requires one of {sorted(SUPPORTED_KEMS)}. Refusing rather than downgrading.")
144
+ # INTERSECTED WITH `SUPPORTED_KEMS`, not merely with what the peer offered — and this is the floor
145
+ # that matters on the wire. This line used to read `KEM_SUITE if KEM_SUITE in offered`, so a
146
+ # configured CLASSICAL suite was selected whenever the peer happened to offer it alongside the
147
+ # hybrid one: the peer check passed and the channel was quantum-vulnerable by configuration alone.
148
+ #
149
+ # It is also what makes the import-time DENYLIST safe rather than lax: a suite nobody has
150
+ # implemented can be named in the environment and still cannot be negotiated, because it is not in
151
+ # this set.
152
+ acceptable = offered & SUPPORTED_KEMS
153
+ kem = KEM_SUITE if KEM_SUITE in acceptable else sorted(acceptable)[0]
154
+
155
+ aeads = set(peer_aeads or SUPPORTED_AEADS)
156
+ usable = aeads & SUPPORTED_AEADS
157
+ if not usable:
158
+ raise DowngradeRefused(f"peer offers no acceptable AEAD; need {sorted(SUPPORTED_AEADS)}")
159
+ # Same shape: `usable` is already intersected with SUPPORTED_AEADS, so a configured AEAD outside
160
+ # it cannot be selected — it falls through to an acceptable one rather than being honoured.
161
+ aead = AEAD if AEAD in usable else sorted(usable)[0]
162
+ return kem, aead
163
+
164
+
165
+ @dataclass
166
+ class HybridPublic:
167
+ """What a peer publishes so others can encrypt to it."""
168
+ x25519: bytes
169
+ mlkem: bytes
170
+
171
+ def encode(self) -> bytes:
172
+ return self.x25519 + self.mlkem
173
+
174
+ @staticmethod
175
+ def decode(raw: bytes) -> "HybridPublic":
176
+ if len(raw) != 32 + 1184:
177
+ raise TransportError(f"hybrid public key must be {32 + 1184} bytes, got {len(raw)}")
178
+ return HybridPublic(x25519=raw[:32], mlkem=raw[32:])
179
+
180
+
181
+ @dataclass
182
+ class HybridPrivate:
183
+ """An EPHEMERAL recipient keypair. Ephemeral is the point: forward secrecy means recording
184
+ today's traffic and stealing the key tomorrow still yields nothing (22-B3)."""
185
+ x25519: x25519.X25519PrivateKey
186
+ mlkem: mlkem.MLKEM768PrivateKey
187
+
188
+ def public(self) -> HybridPublic:
189
+ return HybridPublic(
190
+ x25519=self.x25519.public_key().public_bytes_raw(),
191
+ mlkem=self.mlkem.public_key().public_bytes_raw(),
192
+ )
193
+
194
+
195
+ def generate() -> HybridPrivate:
196
+ """A fresh ephemeral hybrid keypair."""
197
+ return HybridPrivate(x25519=x25519.X25519PrivateKey.generate(),
198
+ mlkem=mlkem.MLKEM768PrivateKey.generate())
199
+
200
+
201
+ def _derive(x_secret: bytes, pq_secret: bytes, salt: bytes, info: bytes = _HKDF_INFO) -> bytes:
202
+ """Mix BOTH shared secrets into one key.
203
+
204
+ Concatenating and running them through HKDF is what makes the construction hybrid: the output
205
+ is unpredictable unless an attacker breaks BOTH primitives. Using either alone, or XOR-ing
206
+ them, would forfeit that.
207
+
208
+ `info` DOMAIN-SEPARATES the two constructions that use this. A one-shot `seal` and a
209
+ `begin_session` handshake can hold the same (ephemeral key, KEM ciphertext) pair, and if
210
+ both derived the same key from it, a one-shot frame and a session frame could be made to
211
+ share a key — and the session's nonces are counters starting at zero, so that is a nonce
212
+ collision across constructions. Different info, different key, no crossover.
213
+ """
214
+ return HKDF(algorithm=hashes.SHA256(), length=32, salt=salt,
215
+ info=info).derive(x_secret + pq_secret)
216
+
217
+
218
+ @dataclass
219
+ class Sealed:
220
+ """Ciphertext plus the public handshake material needed to open it.
221
+
222
+ `metadata` rides in the CLEAR and is authenticated (AAD) but never encrypted — it is what
223
+ routing needs, and it is checked so it cannot be tampered with in flight.
224
+ """
225
+ kem: str
226
+ aead: str
227
+ sender_x25519: bytes
228
+ kem_ciphertext: bytes
229
+ nonce: bytes
230
+ ciphertext: bytes
231
+ metadata: dict
232
+
233
+
234
+ def _aead(name: str, key: bytes):
235
+ if name == "AES-256-GCM":
236
+ return AESGCM(key)
237
+ if name == "ChaCha20-Poly1305":
238
+ return ChaCha20Poly1305(key)
239
+ raise DowngradeRefused(f"AEAD {name!r} is not acceptable for a content-bearing hop")
240
+
241
+
242
+ def _aad(metadata: dict, kem: str, aead: str) -> bytes:
243
+ """Metadata is authenticated even though it is public, so a relay cannot re-point a job at a
244
+ different model or region without the recipient noticing."""
245
+ import json
246
+ return json.dumps({"kem": kem, "aead": aead, "md": metadata},
247
+ sort_keys=True, separators=(",", ":")).encode()
248
+
249
+
250
+ def check_metadata(metadata: dict) -> dict:
251
+ """Refuse to put anything content-revealing in the clear (22-A2).
252
+
253
+ Enforced rather than documented: the difference between "only routing metadata is public" and
254
+ "only routing metadata is public unless a caller adds more" is the whole guarantee.
255
+ """
256
+ extra = set(metadata or {}) - PUBLIC_METADATA_KEYS
257
+ if extra:
258
+ raise TransportError(
259
+ f"these keys are not routing metadata and must not travel in the clear: "
260
+ f"{sorted(extra)}. Put them in the sealed payload.")
261
+ return dict(metadata or {})
262
+
263
+
264
+ def seal(plaintext: bytes, recipient: HybridPublic, *, metadata: Optional[dict] = None,
265
+ kem: str = "", aead: str = "") -> Sealed:
266
+ """Encrypt content to a recipient's hybrid public key."""
267
+ kem = kem or KEM_SUITE
268
+ aead = aead or AEAD
269
+ if kem not in SUPPORTED_KEMS:
270
+ raise DowngradeRefused(f"KEM {kem!r} is not hybrid post-quantum")
271
+ md = check_metadata(metadata or {})
272
+
273
+ eph = x25519.X25519PrivateKey.generate()
274
+ x_secret = eph.exchange(x25519.X25519PublicKey.from_public_bytes(recipient.x25519))
275
+ pq_secret, kem_ct = mlkem.MLKEM768PublicKey.from_public_bytes(recipient.mlkem).encapsulate()
276
+
277
+ sender_pub = eph.public_key().public_bytes_raw()
278
+ key = _derive(x_secret, pq_secret, salt=sender_pub + kem_ct[:32])
279
+ nonce = os.urandom(12)
280
+ ct = _aead(aead, key).encrypt(nonce, plaintext, _aad(md, kem, aead))
281
+ return Sealed(kem=kem, aead=aead, sender_x25519=sender_pub, kem_ciphertext=kem_ct,
282
+ nonce=nonce, ciphertext=ct, metadata=md)
283
+
284
+
285
+ def open_sealed(sealed: Sealed, recipient: HybridPrivate) -> bytes:
286
+ """Decrypt at the authorized boundary (22-A3). Raises if anything was tampered with."""
287
+ if sealed.kem not in SUPPORTED_KEMS:
288
+ raise DowngradeRefused(f"refusing to open a {sealed.kem!r} channel")
289
+ try:
290
+ x_secret = recipient.x25519.exchange(
291
+ x25519.X25519PublicKey.from_public_bytes(sealed.sender_x25519))
292
+ pq_secret = recipient.mlkem.decapsulate(sealed.kem_ciphertext)
293
+ key = _derive(x_secret, pq_secret, salt=sealed.sender_x25519 + sealed.kem_ciphertext[:32])
294
+ return _aead(sealed.aead, key).decrypt(
295
+ sealed.nonce, sealed.ciphertext, _aad(sealed.metadata, sealed.kem, sealed.aead))
296
+ except DowngradeRefused:
297
+ raise
298
+ except Exception as e:
299
+ raise TransportError(f"could not open sealed content: {type(e).__name__}") from e
300
+
301
+
302
+ def wire_format(sealed: Sealed) -> dict:
303
+ """What actually goes on the wire. Content is base64 ciphertext; metadata is readable."""
304
+ import base64
305
+
306
+ b64 = lambda b: base64.b64encode(b).decode()
307
+ return {
308
+ "v": 1,
309
+ "kem": sealed.kem,
310
+ "aead": sealed.aead,
311
+ "epk": b64(sealed.sender_x25519),
312
+ "kct": b64(sealed.kem_ciphertext),
313
+ "n": b64(sealed.nonce),
314
+ "ct": b64(sealed.ciphertext),
315
+ "md": sealed.metadata,
316
+ }
317
+
318
+
319
+ def from_wire(frame: dict) -> Sealed:
320
+ import base64
321
+
322
+ d = lambda s: base64.b64decode(s)
323
+ return Sealed(kem=str(frame.get("kem")), aead=str(frame.get("aead")),
324
+ sender_x25519=d(frame["epk"]), kem_ciphertext=d(frame["kct"]),
325
+ nonce=d(frame["n"]), ciphertext=d(frame["ct"]),
326
+ metadata=dict(frame.get("md") or {}))
327
+
328
+
329
+ # ── PER-STREAM SESSIONS ──────────────────────────────────────────────────────────────────────
330
+ # WHY THIS EXISTS (owner's ruling on the measured streaming cost)
331
+ # `seal()` mints a fresh X25519 ephemeral AND a fresh ML-KEM encapsulation for every frame. On a
332
+ # one-shot frame — a job, an embed reply — that is the right shape and the cost is invisible. On
333
+ # a STREAM it is not: measured at +1703 B and ~0.8 ms per chunk, a 500-token completion carrying
334
+ # 2 KiB of answer paid 400 ms of CPU and 831 KiB of handshake to protect 2 KiB. The owner ruled:
335
+ # "result frames — take the per-stream session key. 400 ms and 831 KiB to protect 2 KiB is not a
336
+ # cost worth paying for the same guarantee. The guarantee does not change: no plaintext on the
337
+ # wire, ever."
338
+ #
339
+ # IT IS THE SAME GUARANTEE, AND THIS IS WHY.
340
+ # Every frame of a stream encapsulates to the SAME recipient key — one per-process ephemeral that
341
+ # lives for the life of that process. Forward secrecy is a property of that key's LIFETIME, not
342
+ # of how many times anyone encapsulates to it: an attacker who recovers the recipient's private
343
+ # key opens every frame sealed to it, whether they were sealed with one KEM ciphertext or with
344
+ # two thousand. So per-frame KEM buys nothing the key's lifetime does not already give, and one
345
+ # handshake per stream costs nothing the guarantee needs.
346
+ #
347
+ # WHAT IT DOES CHANGE — AND IT IS THE WHOLE RISK — IS THE NONCE.
348
+ # A one-shot seal has a fresh key per frame, so `os.urandom(12)` is safe. A session REUSES one
349
+ # AES-GCM key across every frame, and under a reused key a repeated nonce is catastrophic: it
350
+ # leaks the XOR of the two plaintexts and hands over the authentication key, so an attacker can
351
+ # forge. At 12 random bytes the birthday bound puts a collision within reach of a long-lived
352
+ # session, and "we'll just be careful" is not a property. So the nonce is a COUNTER, and the
353
+ # counter is enforced structurally at both ends rather than documented:
354
+ # * the nonce IS the sequence number — it is not carried on the wire at all, so there is no
355
+ # field for an attacker to vary independently of the seq the AAD commits to;
356
+ # * `seal_frame` refuses a sequence it has already used, or any sequence at or below the last
357
+ # one it sealed, and burns the number BEFORE encrypting, so even a failed encrypt cannot
358
+ # hand the same nonce back;
359
+ # * `seal_frame` refuses to run off the end of the counter's range rather than wrapping;
360
+ # * `open_frame` REJECTS a replayed sequence and one that arrives further out of order than
361
+ # `REPLAY_WINDOW`, instead of decrypting it — a receiver that accepts a replay makes the
362
+ # sender's discipline worthless;
363
+ # * a frame belongs to exactly one session: the session id is checked, and it is mixed into
364
+ # the key derivation as well, so a frame from another session cannot open here.
365
+
366
+ #: The per-session identifier. 16 random bytes: it names the session on the wire, is committed to
367
+ #: by the AAD, and is mixed into the key so two sessions cannot collide even if a KEM
368
+ #: implementation ever repeated a ciphertext.
369
+ SESSION_ID_BYTES = 16
370
+
371
+ #: The counter's range. The nonce is 12 bytes: 4 reserved zero bytes and this counter big-endian,
372
+ #: so a sequence number is a nonce and nothing else can be. Running out is refused, never wrapped
373
+ #: — wrapping is exactly the nonce reuse this whole construction exists to make impossible.
374
+ MAX_SEQ = 2 ** 64 - 1
375
+
376
+ #: How far out of order a frame may arrive and still be opened. A WebSocket delivers in order, so
377
+ #: in production this is slack, not a requirement — but a receiver with no window at all either
378
+ #: has to accept everything (a replay is then free) or reject anything non-consecutive (one
379
+ #: dropped frame ends the stream). Anything older than this is refused rather than decrypted.
380
+ REPLAY_WINDOW = 64
381
+
382
+ _HKDF_SESSION_INFO = b"frutebox/transport/session/v1"
383
+
384
+
385
+ class NonceReuse(TransportError):
386
+ """A sender asked for a sequence number it has already used on this session.
387
+
388
+ Refused rather than honoured: under a reused AES-GCM key a repeated nonce leaks the XOR of
389
+ both plaintexts and the authentication key with it. There is no degraded mode here — the
390
+ caller gets an exception and the frame is not sealed.
391
+ """
392
+
393
+
394
+ class ReplayRefused(TransportError):
395
+ """A receiver was handed a sequence it has already accepted, or one older than the window.
396
+
397
+ This is refused BEFORE the AEAD is asked to decrypt. A receiver that quietly opens a replay
398
+ turns the sender's counter discipline into decoration.
399
+ """
400
+
401
+
402
+ @dataclass
403
+ class SessionHandshake:
404
+ """The public half of a session: what the peer needs to derive the same key, once.
405
+
406
+ Exactly the material a one-shot `Sealed` carries per frame — an ephemeral X25519 public key
407
+ and an ML-KEM ciphertext — sent once for the whole stream instead of once per frame.
408
+ """
409
+ kem: str
410
+ aead: str
411
+ session_id: bytes
412
+ sender_x25519: bytes
413
+ kem_ciphertext: bytes
414
+
415
+
416
+ @dataclass
417
+ class SessionFrame:
418
+ """One frame on an established session: a sequence number and a ciphertext.
419
+
420
+ No key material, no nonce and no algorithm names — the session already fixed all of those,
421
+ and a field is a thing an attacker can change. `metadata` rides in the clear and is
422
+ authenticated, exactly as it is on a one-shot `Sealed`.
423
+ """
424
+ session_id: bytes
425
+ seq: int
426
+ ciphertext: bytes
427
+ metadata: dict
428
+
429
+
430
+ def _session_nonce(seq: int) -> bytes:
431
+ """The nonce for a sequence number, and the only way a session nonce is ever produced.
432
+
433
+ There is no random path and no second construction: a nonce is a function of the sequence
434
+ number, so "did this session ever reuse a nonce" is answerable by looking at the counter.
435
+ """
436
+ if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0 or seq > MAX_SEQ:
437
+ raise TransportError(f"sequence number out of range for a session nonce: {seq!r}")
438
+ return b"\x00\x00\x00\x00" + seq.to_bytes(8, "big")
439
+
440
+
441
+ def _session_aad(session_id: bytes, seq: int, kem: str, aead: str, metadata: dict) -> bytes:
442
+ """What the AEAD authenticates but does not encrypt.
443
+
444
+ The SEQUENCE NUMBER is in here, which is the point. Without it a frame could be renumbered on
445
+ the path — and since the nonce is derived from the number, a renumbered frame would be
446
+ decrypted under a different nonce and fail confusingly rather than be rejected as the
447
+ tampering it is. The session id is here too, so a frame cannot be moved between sessions.
448
+ """
449
+ import json
450
+ return json.dumps({"kem": kem, "aead": aead, "sid": session_id.hex(), "seq": seq,
451
+ "md": metadata}, sort_keys=True, separators=(",", ":")).encode()
452
+
453
+
454
+ class Session:
455
+ """One established key, used for many frames — with the counter discipline that makes that safe.
456
+
457
+ A session is created at one end by `begin_session` and at the other by `accept_session`. Both
458
+ ends get the same key; each keeps its own counters, because the two directions of a stream are
459
+ not the same stream. In this codebase a session carries one direction (the node's answer home),
460
+ so one end only ever seals and the other only ever opens.
461
+ """
462
+
463
+ def __init__(self, key: bytes, kem: str, aead: str, session_id: bytes,
464
+ handshake: Optional[SessionHandshake] = None):
465
+ self._key = key
466
+ self.kem = kem
467
+ self.aead = aead
468
+ self.session_id = session_id
469
+ #: Set on the INITIATOR only: what the peer needs to accept this session. The sender puts
470
+ #: it on the first frame and never again — that is where the saving comes from.
471
+ self.handshake = handshake
472
+ #: The highest sequence this session has SEALED. Starts below zero so the first frame can
473
+ #: legitimately be sequence 0, and only ever goes up.
474
+ self._sent_high = -1
475
+ #: The highest sequence this session has OPENED, and the window of ones already accepted.
476
+ self._seen_high = -1
477
+ self._seen: set = set()
478
+
479
+ # -- sending ------------------------------------------------------------------------------
480
+ def seal_frame(self, plaintext: bytes, seq: Optional[int] = None, *,
481
+ metadata: Optional[dict] = None) -> SessionFrame:
482
+ """Encrypt one frame under the session key.
483
+
484
+ `seq` defaults to the next number this session has not used, which is what every caller
485
+ in this codebase does; passing one explicitly is allowed so a caller with its own frame
486
+ numbering can use it, and is checked exactly as hard. A number at or below the last one
487
+ sealed raises `NonceReuse` — including the same number twice, which is the catastrophic
488
+ case this method exists to make impossible.
489
+ """
490
+ if seq is None:
491
+ seq = self._sent_high + 1
492
+ if not isinstance(seq, int) or isinstance(seq, bool):
493
+ raise TransportError(f"a sequence number must be an int, got {type(seq).__name__}")
494
+ if seq < 0:
495
+ raise TransportError(f"a sequence number may not be negative: {seq}")
496
+ if seq <= self._sent_high:
497
+ raise NonceReuse(
498
+ f"sequence {seq} has already been used on this session (last sealed: "
499
+ f"{self._sent_high}). Reusing a nonce under a reused AES-GCM key leaks the XOR "
500
+ f"of both plaintexts and the authentication key with it — refusing.")
501
+ if seq > MAX_SEQ:
502
+ raise TransportError(
503
+ f"this session has run out of sequence numbers ({MAX_SEQ}); start a new session "
504
+ f"rather than wrapping the counter, which would reuse a nonce.")
505
+ md = check_metadata(metadata or {})
506
+ # BURN THE NUMBER FIRST. If the encrypt below raises, the caller may well retry — and a
507
+ # retry that got the same sequence back would be the exact reuse this refuses above.
508
+ self._sent_high = seq
509
+ ct = _aead(self.aead, self._key).encrypt(
510
+ _session_nonce(seq), plaintext,
511
+ _session_aad(self.session_id, seq, self.kem, self.aead, md))
512
+ return SessionFrame(session_id=self.session_id, seq=seq, ciphertext=ct, metadata=md)
513
+
514
+ # -- receiving ----------------------------------------------------------------------------
515
+ def open_frame(self, frame: SessionFrame) -> bytes:
516
+ """Decrypt one frame, or refuse it. Never silently accepts a replay.
517
+
518
+ Order matters: the session and the sequence are checked BEFORE the AEAD is asked to do
519
+ anything, and the sequence is recorded as accepted only AFTER it authenticates — so a
520
+ forged frame cannot consume a sequence number and lock out the real one.
521
+ """
522
+ if not isinstance(frame, SessionFrame):
523
+ raise TransportError(f"expected a session frame, got {type(frame).__name__}")
524
+ if frame.session_id != self.session_id:
525
+ raise TransportError(
526
+ "this frame belongs to a different session; refusing to open it here")
527
+ seq = frame.seq
528
+ if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0 or seq > MAX_SEQ:
529
+ raise ReplayRefused(f"frame carries an unusable sequence number: {seq!r}")
530
+ if seq in self._seen:
531
+ raise ReplayRefused(
532
+ f"sequence {seq} has already been accepted on this session — this is a replay")
533
+ if self._seen_high >= 0 and seq <= self._seen_high - REPLAY_WINDOW:
534
+ raise ReplayRefused(
535
+ f"sequence {seq} is more than {REPLAY_WINDOW} behind this session's highest "
536
+ f"({self._seen_high}); it is either a replay or far enough out of order that "
537
+ f"accepting it would mean giving up on detecting one")
538
+ try:
539
+ plaintext = _aead(self.aead, self._key).decrypt(
540
+ _session_nonce(seq), frame.ciphertext,
541
+ _session_aad(self.session_id, seq, self.kem, self.aead, frame.metadata))
542
+ except DowngradeRefused:
543
+ raise
544
+ except Exception as e:
545
+ raise TransportError(f"could not open sealed frame: {type(e).__name__}") from e
546
+ self._seen.add(seq)
547
+ self._seen_high = max(self._seen_high, seq)
548
+ # Keep the window bounded: a long stream is thousands of frames, and an unbounded set of
549
+ # accepted sequence numbers is a memory leak that grows with the user's answer.
550
+ self._seen = {s for s in self._seen if s > self._seen_high - REPLAY_WINDOW}
551
+ return plaintext
552
+
553
+
554
+ def begin_session(recipient: HybridPublic, *, kem: str = "",
555
+ aead: str = "") -> Tuple[Session, SessionHandshake]:
556
+ """Start a session to a recipient's hybrid public key. One handshake, then AEAD only.
557
+
558
+ Returns the session and the handshake the recipient needs. The handshake is public material —
559
+ an ephemeral public key and a KEM ciphertext, the same two fields `wire_format` already puts
560
+ in the clear on every one-shot frame — so it is not a secret. What it is NOT is optional:
561
+ without it the peer has no key, so it is sent on the session's first frame.
562
+ """
563
+ kem = kem or KEM_SUITE
564
+ aead = aead or AEAD
565
+ if kem not in SUPPORTED_KEMS:
566
+ raise DowngradeRefused(f"KEM {kem!r} is not hybrid post-quantum")
567
+ if aead not in SUPPORTED_AEADS:
568
+ raise DowngradeRefused(f"AEAD {aead!r} is not acceptable for a content-bearing hop")
569
+
570
+ eph = x25519.X25519PrivateKey.generate()
571
+ x_secret = eph.exchange(x25519.X25519PublicKey.from_public_bytes(recipient.x25519))
572
+ pq_secret, kem_ct = mlkem.MLKEM768PublicKey.from_public_bytes(recipient.mlkem).encapsulate()
573
+ sender_pub = eph.public_key().public_bytes_raw()
574
+ session_id = os.urandom(SESSION_ID_BYTES)
575
+ key = _derive(x_secret, pq_secret, salt=session_id + sender_pub + kem_ct[:32],
576
+ info=_HKDF_SESSION_INFO)
577
+ handshake = SessionHandshake(kem=kem, aead=aead, session_id=session_id,
578
+ sender_x25519=sender_pub, kem_ciphertext=kem_ct)
579
+ return Session(key, kem, aead, session_id, handshake=handshake), handshake
580
+
581
+
582
+ def accept_session(handshake: SessionHandshake, recipient: HybridPrivate) -> Session:
583
+ """Derive the other end of a session from its handshake and the recipient's private key."""
584
+ if handshake.kem not in SUPPORTED_KEMS:
585
+ raise DowngradeRefused(f"refusing to accept a {handshake.kem!r} session")
586
+ if handshake.aead not in SUPPORTED_AEADS:
587
+ raise DowngradeRefused(f"refusing to accept a session under AEAD {handshake.aead!r}")
588
+ if len(handshake.session_id) != SESSION_ID_BYTES:
589
+ raise TransportError(
590
+ f"a session id must be {SESSION_ID_BYTES} bytes, got {len(handshake.session_id)}")
591
+ try:
592
+ x_secret = recipient.x25519.exchange(
593
+ x25519.X25519PublicKey.from_public_bytes(handshake.sender_x25519))
594
+ pq_secret = recipient.mlkem.decapsulate(handshake.kem_ciphertext)
595
+ except Exception as e:
596
+ raise TransportError(f"could not accept this session: {type(e).__name__}") from e
597
+ key = _derive(x_secret, pq_secret,
598
+ salt=handshake.session_id + handshake.sender_x25519
599
+ + handshake.kem_ciphertext[:32], info=_HKDF_SESSION_INFO)
600
+ return Session(key, handshake.kem, handshake.aead, handshake.session_id)
601
+
602
+
603
+ def session_handshake_wire(handshake: SessionHandshake) -> dict:
604
+ """The handshake as it travels. Public material only — no private key ever leaves here."""
605
+ import base64
606
+
607
+ b64 = lambda b: base64.b64encode(b).decode()
608
+ return {"v": 1, "kem": handshake.kem, "aead": handshake.aead,
609
+ "sid": b64(handshake.session_id), "epk": b64(handshake.sender_x25519),
610
+ "kct": b64(handshake.kem_ciphertext)}
611
+
612
+
613
+ def session_handshake_from_wire(frame: dict) -> SessionHandshake:
614
+ import base64
615
+
616
+ d = lambda s: base64.b64decode(s)
617
+ return SessionHandshake(kem=str(frame.get("kem")), aead=str(frame.get("aead")),
618
+ session_id=d(frame["sid"]), sender_x25519=d(frame["epk"]),
619
+ kem_ciphertext=d(frame["kct"]))
620
+
621
+
622
+ def session_wire_format(frame: SessionFrame, *,
623
+ handshake: Optional[SessionHandshake] = None) -> dict:
624
+ """What a session frame puts on the wire.
625
+
626
+ No nonce field, deliberately: the nonce is the sequence number, so there is nothing for a
627
+ relay to vary independently of the number the AAD commits to. No `kem`/`aead` either — the
628
+ session fixed those at the handshake, and a per-frame algorithm name is a per-frame downgrade
629
+ offer. `hs` is present on the FIRST frame of a session and absent on every one after it,
630
+ which is the entire saving.
631
+ """
632
+ import base64
633
+
634
+ out = {"v": 1, "sid": base64.b64encode(frame.session_id).decode(), "seq": frame.seq,
635
+ "ct": base64.b64encode(frame.ciphertext).decode(), "md": frame.metadata}
636
+ if handshake is not None:
637
+ out["hs"] = session_handshake_wire(handshake)
638
+ return out
639
+
640
+
641
+ def session_from_wire(frame: dict) -> SessionFrame:
642
+ import base64
643
+
644
+ seq = frame.get("seq")
645
+ if not isinstance(seq, int) or isinstance(seq, bool):
646
+ raise TransportError(f"a session frame needs an integer sequence number, got {seq!r}")
647
+ return SessionFrame(session_id=base64.b64decode(frame["sid"]), seq=seq,
648
+ ciphertext=base64.b64decode(frame["ct"]),
649
+ metadata=dict(frame.get("md") or {}))
650
+
651
+
652
+ def is_session_frame(sealed: object) -> bool:
653
+ """Is this wire dict a session frame, or a one-shot `Sealed`?
654
+
655
+ Both forms are accepted on the reply path for the length of the cutover: a node on an older
656
+ build still seals each frame on its own, and a reader that could only take the new shape
657
+ would be the flag day the staging exists to avoid. The discriminator is `sid`, which only a
658
+ session frame carries.
659
+ """
660
+ return isinstance(sealed, dict) and bool(sealed.get("sid"))