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.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/pqctransport.py
ADDED
|
@@ -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"))
|