ziffer 0.1.0__tar.gz

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.
ziffer-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: ziffer
3
+ Version: 0.1.0
4
+ Summary: Ziffer decision API client and ACP receipt verifier (wraps the reference implementation at the engine pin)
5
+ License: Proprietary
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=42
9
+ Requires-Dist: dilithium-py>=1.0
10
+
11
+ # ziffer — Python SDK (ACP-197)
12
+
13
+ Client for the Ziffer decision API plus the customer-side receipt verifier.
14
+
15
+ The verifier **wraps the ACP reference implementation** — it calls the
16
+ reference's own functions (`canon`, `h`, `sig_ok`, `Timestamp.parse`,
17
+ `Bundle.suite_ok`, the WE-4/L-17 nonce rules) and never reimplements them.
18
+ Refusal names are the reference's clause ids, identically. The committed
19
+ source here is the wrapper only; `build.sh` copies the needed
20
+ `reference/src/*.py` modules from an engine checkout **at the pin** into
21
+ `src/ziffer/_engine/` (gitignored) at build/test time. This is build-time
22
+ distribution under the public freeze, not a fork: the copy is verbatim,
23
+ header-stamped with its source rev, and regenerated on every build.
24
+
25
+ ```sh
26
+ ./build.sh # copies the engine at the pin
27
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
28
+ python3 -m build # sdist + wheel (engine included)
29
+ ```
30
+
31
+ ```python
32
+ import ziffer
33
+
34
+ client = ziffer.Client("https://api.example", api_key="zfr_...")
35
+ d = client.propose(proposal) # -> Decision(decision_id, status, ...)
36
+ d = client.wait(d.decision_id, timeout=30)
37
+
38
+ anchor = ziffer.TrustAnchor.from_file("receipt-identity.pub.json") # acp-bundle pubkey output
39
+ verified = ziffer.verify(d.receipt, proposal_bytes, anchor) # or raises RefusedError(name)
40
+ ```
41
+
42
+ `verify()` runs the **stateless** §9.3 subset (AB-0, AB-6, CR-4, 9.3-1/CR-3,
43
+ 9.3-2, 9.3-3, 9.3-5/L-14, WE-4/L-17). It does not — cannot, statelessly —
44
+ check the policy basis (step 4), nonce single-use (CL-2), or steps 7–10;
45
+ a `Verified` means the stateless half found nothing, not the full §9.3 verdict.
ziffer-0.1.0/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # ziffer — Python SDK (ACP-197)
2
+
3
+ Client for the Ziffer decision API plus the customer-side receipt verifier.
4
+
5
+ The verifier **wraps the ACP reference implementation** — it calls the
6
+ reference's own functions (`canon`, `h`, `sig_ok`, `Timestamp.parse`,
7
+ `Bundle.suite_ok`, the WE-4/L-17 nonce rules) and never reimplements them.
8
+ Refusal names are the reference's clause ids, identically. The committed
9
+ source here is the wrapper only; `build.sh` copies the needed
10
+ `reference/src/*.py` modules from an engine checkout **at the pin** into
11
+ `src/ziffer/_engine/` (gitignored) at build/test time. This is build-time
12
+ distribution under the public freeze, not a fork: the copy is verbatim,
13
+ header-stamped with its source rev, and regenerated on every build.
14
+
15
+ ```sh
16
+ ./build.sh # copies the engine at the pin
17
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
18
+ python3 -m build # sdist + wheel (engine included)
19
+ ```
20
+
21
+ ```python
22
+ import ziffer
23
+
24
+ client = ziffer.Client("https://api.example", api_key="zfr_...")
25
+ d = client.propose(proposal) # -> Decision(decision_id, status, ...)
26
+ d = client.wait(d.decision_id, timeout=30)
27
+
28
+ anchor = ziffer.TrustAnchor.from_file("receipt-identity.pub.json") # acp-bundle pubkey output
29
+ verified = ziffer.verify(d.receipt, proposal_bytes, anchor) # or raises RefusedError(name)
30
+ ```
31
+
32
+ `verify()` runs the **stateless** §9.3 subset (AB-0, AB-6, CR-4, 9.3-1/CR-3,
33
+ 9.3-2, 9.3-3, 9.3-5/L-14, WE-4/L-17). It does not — cannot, statelessly —
34
+ check the policy basis (step 4), nonce single-use (CL-2), or steps 7–10;
35
+ a `Verified` means the stateless half found nothing, not the full §9.3 verdict.
@@ -0,0 +1,29 @@
1
+ # ziffer Python SDK (ACP-197). setuptools, not hatchling, and the choice is
2
+ # load-bearing: hatchling excludes gitignored files from builds by default, and
3
+ # src/ziffer/_engine/ is gitignored on purpose (committed source = wrapper
4
+ # only; build.sh copies the reference modules in from the pinned engine
5
+ # checkout). setuptools includes every .py of a discovered package regardless
6
+ # of VCS state, so the wheel carries the engine iff build.sh ran — and
7
+ # `ziffer.verify` names build.sh when it did not, rather than half-working.
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=69"]
11
+ build-backend = "setuptools.build_meta"
12
+
13
+ [project]
14
+ name = "ziffer"
15
+ version = "0.1.0"
16
+ description = "Ziffer decision API client and ACP receipt verifier (wraps the reference implementation at the engine pin)"
17
+ readme = "README.md"
18
+ requires-python = ">=3.10"
19
+ license = { text = "Proprietary" }
20
+ # The engine's own two dependencies, nothing else: the client half is stdlib
21
+ # urllib by runbook §4 rule (a third-party HTTP client is a dependency with
22
+ # no justification here).
23
+ dependencies = [
24
+ "cryptography>=42",
25
+ "dilithium-py>=1.0",
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
ziffer-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """ziffer — client and receipt verifier for the Ziffer decision API (ACP-197).
2
+
3
+ Two halves, deliberately separable:
4
+
5
+ - `Client` / `Decision` (stdlib-only): talk to the public decision API.
6
+ - `verify` / `TrustAnchor` / `Verified`: run the stateless §9.3 receipt
7
+ checks by WRAPPING the ACP reference implementation, vendored at the
8
+ engine pin by `sdk/python/build.sh`. Refusal names are the reference's
9
+ clause ids, identically.
10
+
11
+ The client works without the vendored engine; anything touching a receipt's
12
+ cryptography raises `EngineAbsent` (naming build.sh) until the copy exists.
13
+ That split is honest rather than convenient: importing this package must not
14
+ fail on a machine that only submits proposals, and verifying must never
15
+ degrade to "engine missing, skipped".
16
+ """
17
+ from .client import Client, Decision
18
+ from .errors import (AnchorError, ApiError, EngineAbsent, RefusedError,
19
+ WaitTimeout, ZifferError)
20
+ from .verify import TrustAnchor, Verified, verify
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "Client", "Decision",
26
+ "TrustAnchor", "Verified", "verify",
27
+ "ZifferError", "RefusedError", "ApiError", "WaitTimeout",
28
+ "EngineAbsent", "AnchorError",
29
+ "__version__",
30
+ ]
@@ -0,0 +1 @@
1
+ SOURCE_REV = "356ef8eaed7eed2a0d5a0ab9917892dab3e15a8c"
@@ -0,0 +1,454 @@
1
+ # ziffer build-time copy — DO NOT EDIT (regenerated by sdk/python/build.sh)
2
+ # source: reference/src/acp_crypto.py @ 356ef8eaed7eed2a0d5a0ab9917892dab3e15a8c (engine pin 356ef8e)
3
+ #!/usr/bin/env python3
4
+ """
5
+ acp_crypto.py — real cryptography and canonical encoding for ACP v1.3.8.
6
+
7
+ Closes two CRYPTO-SWAP / encoding gaps that the reference Executor previously
8
+ modelled:
9
+
10
+ 1. HYBRID SIGNATURES (CR-1..CR-3) with real primitives:
11
+ classical -> Ed25519 (RFC 8032, via `cryptography`)
12
+ pq -> ML-DSA-65 (FIPS 204, via `dilithium-py`)
13
+ Composition remains conjunctive; only the primitives changed.
14
+
15
+ 2. CANONICAL CBOR (AT-8a) per RFC 8949 §4.2.1 deterministic encoding, with a
16
+ VALIDATING decoder. AT-8a requires that a non-canonical encoding be
17
+ REJECTED rather than re-serialised and accepted; a permissive decoder
18
+ silently normalises and reopens Z4. The decoder here therefore re-encodes
19
+ what it parsed and refuses any input that is not byte-identical.
20
+
21
+ MEASUREMENT NOTE. Sizes and timings are reported by `bench()` rather than
22
+ asserted from memory: ML-DSA-65 signatures are ~3.3 kB against Ed25519's 64 B,
23
+ which is the fact §9.7's performance note requires deployments to re-measure.
24
+ """
25
+ from __future__ import annotations
26
+ import hashlib, io, time
27
+ from typing import Any
28
+
29
+ from cryptography.hazmat.primitives import serialization
30
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
31
+ Ed25519PrivateKey, Ed25519PublicKey)
32
+ from cryptography.exceptions import InvalidSignature
33
+ from dilithium_py.ml_dsa import ML_DSA_65
34
+
35
+
36
+ class CanonError(Exception):
37
+ """Encoding is not the canonical one. AT-8a: reject, never normalise."""
38
+
39
+
40
+ # =============================================================== canonical CBOR
41
+ def _enc_head(major: int, val: int, out: io.BytesIO):
42
+ """RFC 8949 §4.2.1: the argument MUST use the shortest form that holds it."""
43
+ if val < 24:
44
+ out.write(bytes([(major << 5) | val]))
45
+ elif val < 0x100:
46
+ out.write(bytes([(major << 5) | 24, val]))
47
+ elif val < 0x10000:
48
+ out.write(bytes([(major << 5) | 25]) + val.to_bytes(2, "big"))
49
+ elif val < 0x100000000:
50
+ out.write(bytes([(major << 5) | 26]) + val.to_bytes(4, "big"))
51
+ elif val < 0x10000000000000000:
52
+ out.write(bytes([(major << 5) | 27]) + val.to_bytes(8, "big"))
53
+ else:
54
+ raise CanonError("integer exceeds 64-bit CBOR argument")
55
+
56
+
57
+ def _enc(obj: Any, out: io.BytesIO):
58
+ if obj is True:
59
+ out.write(b"\xf5"); return
60
+ if obj is False:
61
+ out.write(b"\xf4"); return
62
+ if obj is None:
63
+ out.write(b"\xf6"); return
64
+ if isinstance(obj, float):
65
+ # WE-1/AT-8a: floats have multiple representations of one value and no
66
+ # deterministic ordering story. Excluded from signed structures.
67
+ raise CanonError("float in a canonical structure")
68
+ if isinstance(obj, int):
69
+ if obj >= 0:
70
+ _enc_head(0, obj, out)
71
+ else:
72
+ _enc_head(1, -obj - 1, out)
73
+ return
74
+ if isinstance(obj, bytes):
75
+ _enc_head(2, len(obj), out); out.write(obj); return
76
+ if isinstance(obj, str):
77
+ b = obj.encode("utf-8"); _enc_head(3, len(b), out); out.write(b); return
78
+ if isinstance(obj, (list, tuple)):
79
+ _enc_head(4, len(obj), out)
80
+ for x in obj:
81
+ _enc(x, out)
82
+ return
83
+ if isinstance(obj, dict):
84
+ # RFC 8949 §4.2.1: map keys sorted by their ENCODED bytes, bytewise
85
+ # lexicographic. Sorting by the Python value would differ for e.g.
86
+ # integer vs string keys and for non-ASCII.
87
+ items = []
88
+ for k, v in obj.items():
89
+ kb = io.BytesIO(); _enc(k, kb); items.append((kb.getvalue(), v))
90
+ items.sort(key=lambda kv: kv[0])
91
+ if len({k for k, _ in items}) != len(items):
92
+ raise CanonError("duplicate map key")
93
+ _enc_head(5, len(items), out)
94
+ for kb, v in items:
95
+ out.write(kb); _enc(v, out)
96
+ return
97
+ raise CanonError(f"type {type(obj).__name__} not encodable")
98
+
99
+
100
+ def canon_cbor(obj: Any) -> bytes:
101
+ out = io.BytesIO(); _enc(obj, out); return out.getvalue()
102
+
103
+
104
+ def _dec(buf: bytes, i: int):
105
+ if i >= len(buf):
106
+ raise CanonError("truncated")
107
+ ib = buf[i]; major, ai = ib >> 5, ib & 0x1F; i += 1
108
+ if ai < 24:
109
+ val = ai
110
+ elif ai == 24:
111
+ val = buf[i]; i += 1
112
+ if major not in (7,) and val < 24:
113
+ raise CanonError("non-shortest argument")
114
+ elif ai == 25:
115
+ val = int.from_bytes(buf[i:i+2], "big"); i += 2
116
+ if val < 0x100: raise CanonError("non-shortest argument")
117
+ elif ai == 26:
118
+ val = int.from_bytes(buf[i:i+4], "big"); i += 4
119
+ if val < 0x10000: raise CanonError("non-shortest argument")
120
+ elif ai == 27:
121
+ val = int.from_bytes(buf[i:i+8], "big"); i += 8
122
+ if val < 0x100000000: raise CanonError("non-shortest argument")
123
+ elif ai == 31:
124
+ raise CanonError("indefinite length forbidden in canonical CBOR")
125
+ else:
126
+ raise CanonError(f"reserved additional information {ai}")
127
+
128
+ if major == 0: return val, i
129
+ if major == 1: return -val - 1, i
130
+ if major == 2: return buf[i:i+val], i + val
131
+ if major == 3: return buf[i:i+val].decode("utf-8"), i + val
132
+ if major == 4:
133
+ out = []
134
+ for _ in range(val):
135
+ x, i = _dec(buf, i); out.append(x)
136
+ return out, i
137
+ if major == 5:
138
+ out, prev = {}, None
139
+ for _ in range(val):
140
+ kstart = i
141
+ k, i = _dec(buf, i)
142
+ kb = buf[kstart:i]
143
+ if prev is not None and kb <= prev:
144
+ raise CanonError("map keys not in canonical order")
145
+ prev = kb
146
+ v, i = _dec(buf, i)
147
+ out[k] = v
148
+ return out, i
149
+ if major == 7:
150
+ if val == 20: return False, i
151
+ if val == 21: return True, i
152
+ if val == 22: return None, i
153
+ raise CanonError("float or unsupported simple value")
154
+ raise CanonError("unreachable")
155
+
156
+
157
+ def decode_canonical(buf: bytes) -> Any:
158
+ """
159
+ AT-8a: parse AND verify canonicity. Any input that is not byte-identical to
160
+ the canonical encoding of what it decodes to is REFUSED, never normalised.
161
+ """
162
+ obj, i = _dec(buf, 0)
163
+ if i != len(buf):
164
+ raise CanonError("trailing bytes")
165
+ if canon_cbor(obj) != buf:
166
+ raise CanonError("input is not the canonical encoding of its value")
167
+ return obj
168
+
169
+
170
+ def h_cbor(obj: Any) -> str:
171
+ return "sha256:" + hashlib.sha256(canon_cbor(obj)).hexdigest()
172
+
173
+
174
+ # ============================================================ hybrid signatures
175
+ class HybridKey:
176
+ """
177
+ One identity, one key per primitive. Private halves stay together.
178
+
179
+ DERIVED FROM THE SEED, both legs. Through v1.3.13 the ML-DSA half came from
180
+ an unseeded `ML_DSA_65.keygen()`, which was invisible while only one process
181
+ ever used a key: `sim.supervise` runs seven real OS processes, each importing
182
+ `sim.world`, and each would have minted a DIFFERENT post-quantum keypair for
183
+ the same identity — the classical leg would verify and the PQ leg would not,
184
+ so every hybrid signature would fail closed across a process boundary. FIPS
185
+ 204 specifies KeyGen_internal(xi) over a 32-byte seed for exactly this.
186
+
187
+ A seed is test-and-simulation key material. A deployment loads keys from a
188
+ KMS or an HSM; nothing here should be read as endorsing derived keys.
189
+ """
190
+ def __init__(self, seed: bytes):
191
+ self.ed_sk = Ed25519PrivateKey.from_private_bytes(
192
+ hashlib.sha256(seed + b"ed").digest())
193
+ self.ed_pk = self.ed_sk.public_key()
194
+ self.ml_pk, self.ml_sk = ML_DSA_65.key_derive(
195
+ hashlib.sha256(seed + b"mldsa").digest())
196
+
197
+ def public(self) -> "HybridPub":
198
+ return HybridPub(self.ed_pk, self.ml_pk)
199
+
200
+
201
+ class HybridPub:
202
+ def __init__(self, ed_pk: Ed25519PublicKey, ml_pk: bytes):
203
+ self.ed_pk, self.ml_pk = ed_pk, ml_pk
204
+
205
+ def fingerprint(self) -> str:
206
+ """
207
+ A name for this public identity, over BOTH halves.
208
+
209
+ Used to bring the key registry inside `Bundle.hash()` (spec §8.2: the
210
+ SIGNATURE is over the canonical bundle tree, and `attesters/` is in that
211
+ tree). Covering both primitives is the point — a fingerprint over the
212
+ classical half alone would let an ML-DSA key be swapped without moving
213
+ the bundle hash, which is the conjunctive CR-3 guarantee undone at the
214
+ registry instead of at the verifier.
215
+ """
216
+ raw = self.ed_pk.public_bytes(
217
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
218
+ return "sha256:" + hashlib.sha256(raw + self.ml_pk).hexdigest()
219
+
220
+
221
+ PRIMS = {"classical", "pq"}
222
+
223
+
224
+ # ------------------------------------------------- per-primitive sign / verify
225
+ #
226
+ # The Executor owns SUITES, SUITE_RANK and the conjunctive composition (CR-3);
227
+ # this module owns the primitives. Keeping that split is deliberate: a mutation
228
+ # of the composition (`all` -> `any`) still has something real to break, and the
229
+ # CR-4 suite floor stays protocol logic rather than a property of a crypto
230
+ # library.
231
+
232
+
233
+ # ------------------------------------------ Ed25519 small-order guard (ACP-106)
234
+ #
235
+ # `cryptography` verifies Ed25519 through OpenSSL, which implements RFC 8032 as
236
+ # written and does NOT refuse a public key of small order. Under such a key the
237
+ # verification equation `[s]B == R + [k]A` loses its `A` term -- for the
238
+ # identity point `[k]A` is the identity whatever `k` is -- and what remains,
239
+ # `[s]B == R`, does not mention the message at all. ONE signature then verifies
240
+ # under that key for EVERY message.
241
+ #
242
+ # That is a fail-OPEN primitive, and the damage is a quorum bypass: an attester
243
+ # whose registered classical key is small-order has its attestations mintable by
244
+ # anyone who has seen that key, so a single holder satisfies a k=2 quorum alone.
245
+ # That is the ACP-53 shape reached by a different route. CR-3's conjunction
246
+ # masks it under `hybrid-ed25519-mldsa65`, because the post-quantum half still
247
+ # has to verify -- but `ed25519` is a declared legacy suite in SUITES, and under
248
+ # a classical floor nothing else stands in the way.
249
+ #
250
+ # `ed25519-dalek` puts this rule behind `verify_strict`, which
251
+ # crates/acp-crypto/src/primitives.rs now calls; this is the Python half of the
252
+ # same fix. THE TWO MUST STAY THE SAME RULE. The differential compares the two
253
+ # implementations against each other, so a guard on one side only would convert
254
+ # a closed defect into a divergence -- and note that the differential could not
255
+ # see this defect at all while both sides were wrong in the same way, which is
256
+ # the standing warning in dossier/05-TEST-EVIDENCE.md landing on a real case.
257
+ #
258
+ # The rule is `[8]P == identity`, exactly dalek's `is_small_order()`, written
259
+ # out rather than kept as a table of encodings: a hardcoded blacklist is a
260
+ # second definition of "small order" that nothing can check against the curve.
261
+
262
+ _ED_P = 2 ** 255 - 19
263
+ _ED_D = (-121665 * pow(121666, _ED_P - 2, _ED_P)) % _ED_P
264
+ _ED_SQRT_M1 = pow(2, (_ED_P - 1) // 4, _ED_P)
265
+
266
+
267
+ def _ed_decompress(b: bytes):
268
+ """RFC 8032 5.1.3 point decompression. None when `b` is not a curve point."""
269
+ if len(b) != 32:
270
+ return None
271
+ y = int.from_bytes(b, "little")
272
+ sign, y = y >> 255, y & ((1 << 255) - 1)
273
+ u = (y * y - 1) % _ED_P
274
+ v = (_ED_D * y * y + 1) % _ED_P
275
+ x = (u * pow(v, 3, _ED_P)
276
+ * pow(u * pow(v, 7, _ED_P), (_ED_P - 5) // 8, _ED_P)) % _ED_P
277
+ if (v * x * x - u) % _ED_P:
278
+ if (v * x * x + u) % _ED_P:
279
+ return None
280
+ x = x * _ED_SQRT_M1 % _ED_P
281
+ if x == 0 and sign:
282
+ return None
283
+ return (_ED_P - x if x % 2 != sign else x), y
284
+
285
+
286
+ def _ed_is_small_order(b: bytes) -> bool:
287
+ """
288
+ True when the encoded point has order dividing 8.
289
+
290
+ A point that does not decode is NOT reported small-order. It is refused a
291
+ line later by the verifier itself, and conflating "not a point" with "small
292
+ order" would make this guard's own failure mode unreadable.
293
+ """
294
+ pt = _ed_decompress(b)
295
+ if pt is None:
296
+ return False
297
+ X, Y, Z = pt[0], pt[1], 1
298
+ for _ in range(3): # [8]P, by three doublings
299
+ a, bb = X * X % _ED_P, Y * Y % _ED_P
300
+ c = 2 * Z * Z % _ED_P
301
+ hh = (a + bb) % _ED_P
302
+ e = (hh - (X + Y) * (X + Y)) % _ED_P
303
+ g = (a - bb) % _ED_P
304
+ f = (c + g) % _ED_P
305
+ X, Y, Z = e * f % _ED_P, g * hh % _ED_P, f * g % _ED_P
306
+ return X % _ED_P == 0 and (Y - Z) % _ED_P == 0 # identity is (0 : 1 : 1)
307
+
308
+
309
+ # The same predicate under a public name, for the bundle loader (ACP-109,
310
+ # PB-9). ONE definition, two callers: `verify_prim` refuses a small-order key
311
+ # at verification and `acp_bundle.BundleHost._check_registry` refuses it at
312
+ # enrolment. A second copy in the loader would be a second definition of
313
+ # "small order" that the two could drift apart on -- which is the reason the
314
+ # Rust side exposes dalek's `is_weak` rather than re-deriving it. The
315
+ # underscore name stays as the one `verify_prim` calls, because
316
+ # `mutate_executor.py` anchors the ACP-106 mutant on that source line.
317
+ ed25519_is_small_order = _ed_is_small_order
318
+
319
+
320
+ def sign_prim(key: HybridKey, msg: bytes, prim: str) -> bytes:
321
+ if prim == "classical":
322
+ return key.ed_sk.sign(msg)
323
+ if prim == "pq":
324
+ return ML_DSA_65.sign(key.ml_sk, msg)
325
+ raise CanonError(f"no primitive {prim!r}")
326
+
327
+
328
+ def verify_prim(pub: HybridPub, msg: bytes, sig_hex: Any, prim: str) -> bool:
329
+ """
330
+ Never raises. A malformed signature is a verification FAILURE, not an
331
+ exception for some caller further up to catch — a raise here would travel a
332
+ different path from a false, and the two must be indistinguishable to the
333
+ Executor's fail-closed contract.
334
+ """
335
+ if not isinstance(sig_hex, str):
336
+ return False
337
+ try:
338
+ raw = bytes.fromhex(sig_hex)
339
+ except ValueError:
340
+ return False
341
+ if prim == "classical":
342
+ # ACP-106-small-order-guard (mutation target). Both halves, because
343
+ # `verify_strict` refuses both and the two implementations must refuse
344
+ # the same bytes: a small-order A makes one signature verify for every
345
+ # message; a small-order R lets a signer emit an r=0 signature that a
346
+ # non-strict verifier accepts over a message it never committed to.
347
+ ed_pk_raw = pub.ed_pk.public_bytes(
348
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
349
+ if _ed_is_small_order(ed_pk_raw) or _ed_is_small_order(raw[:32]):
350
+ return False
351
+ try:
352
+ pub.ed_pk.verify(raw, msg)
353
+ return True
354
+ except Exception:
355
+ return False
356
+ if prim == "pq":
357
+ try:
358
+ return bool(ML_DSA_65.verify(pub.ml_pk, msg, raw))
359
+ except Exception:
360
+ return False
361
+ return False
362
+
363
+
364
+ def sign_hybrid(key: HybridKey, msg: bytes, alg: str) -> dict:
365
+ """CR-2: one signature value per primitive in the suite."""
366
+ sig = {}
367
+ if alg in ("ed25519", "hybrid-ed25519-mldsa65"):
368
+ sig["classical"] = key.ed_sk.sign(msg)
369
+ if alg in ("mldsa65", "hybrid-ed25519-mldsa65"):
370
+ sig["pq"] = ML_DSA_65.sign(key.ml_sk, msg)
371
+ if not sig:
372
+ raise CanonError(f"unknown suite {alg}")
373
+ return sig
374
+
375
+
376
+ def verify_hybrid(pub: HybridPub, msg: bytes, sig: Any, alg: str) -> bool:
377
+ """
378
+ CR-3: conjunctive. Every primitive in the suite must verify, and the set of
379
+ supplied primitives must match the suite exactly.
380
+ """
381
+ expected = set()
382
+ if alg in ("ed25519", "hybrid-ed25519-mldsa65"): expected.add("classical")
383
+ if alg in ("mldsa65", "hybrid-ed25519-mldsa65"): expected.add("pq")
384
+ if not expected or not isinstance(sig, dict) or set(sig) != expected:
385
+ return False
386
+ if "classical" in expected:
387
+ try:
388
+ pub.ed_pk.verify(sig["classical"], msg)
389
+ except (InvalidSignature, Exception):
390
+ return False
391
+ if "pq" in expected:
392
+ try:
393
+ if not ML_DSA_65.verify(pub.ml_pk, msg, sig["pq"]):
394
+ return False
395
+ except Exception:
396
+ return False
397
+ return True
398
+
399
+
400
+ # ==================================================================== benchmark
401
+ SIGS_PER_HIGH_RECEIPT = 4 # 1 receipt signature + 3 attestations (quorum k=3)
402
+
403
+
404
+ def bench(n: int = 200) -> dict:
405
+ """§9.7 requires deployments to MEASURE against EO-2, not assume."""
406
+ k = HybridKey(b"bench"); pub = k.public()
407
+ msg = canon_cbor({"proposal_hash": "sha256:" + "ab" * 32, "epoch": 47,
408
+ "targets": ["prod-db"], "action": "allow", "port": 22})
409
+ out = {"payload_bytes": len(msg)}
410
+ for alg in ("ed25519", "hybrid-ed25519-mldsa65"):
411
+ t0 = time.perf_counter()
412
+ for _ in range(n):
413
+ s = sign_hybrid(k, msg, alg)
414
+ t1 = time.perf_counter()
415
+ for _ in range(n):
416
+ verify_hybrid(pub, msg, s, alg)
417
+ t2 = time.perf_counter()
418
+ # true p99 over per-verification samples, not a mean
419
+ samples = []
420
+ for _ in range(n):
421
+ a = time.perf_counter(); verify_hybrid(pub, msg, s, alg)
422
+ samples.append((time.perf_counter() - a) * 1000)
423
+ samples.sort()
424
+ p99 = samples[min(len(samples) - 1, int(0.99 * len(samples)))]
425
+ per_sig = sum(len(v) for v in s.values())
426
+ out[alg] = {
427
+ "sig_bytes": per_sig,
428
+ "receipt_bytes": per_sig * SIGS_PER_HIGH_RECEIPT,
429
+ "sign_ms": (t1 - t0) / n * 1000,
430
+ "verify_ms": (t2 - t1) / n * 1000,
431
+ "verify_p99_ms": p99,
432
+ "receipt_verify_p99_ms": p99 * SIGS_PER_HIGH_RECEIPT,
433
+ }
434
+ return out
435
+
436
+
437
+ if __name__ == "__main__":
438
+ import json
439
+ r = bench()
440
+ print(f"canonical CBOR payload: {r.pop('payload_bytes')} bytes\n")
441
+ for alg, m in r.items():
442
+ print(f"{alg:26s} sig={m['sig_bytes']:>5} B "
443
+ f"sign={m['sign_ms']:.3f} ms verify={m['verify_ms']:.3f} ms "
444
+ f"p99={m['verify_p99_ms']:.3f} ms")
445
+ print(f"\nPer floor-HIGH receipt ({SIGS_PER_HIGH_RECEIPT} signatures: "
446
+ f"1 receipt + 3 attestations, quorum k=3):")
447
+ for alg, m in r.items():
448
+ print(f" {alg:26s} {m['receipt_bytes']:>6} B on the wire, "
449
+ f"verify p99 {m['receipt_verify_p99_ms']:.1f} ms")
450
+ c, h = r["ed25519"], r["hybrid-ed25519-mldsa65"]
451
+ print(f"\n size factor : {h['receipt_bytes']/c['receipt_bytes']:.1f}x "
452
+ f"({c['receipt_bytes']} B -> {h['receipt_bytes']} B) [algorithm-bound]")
453
+ print(f" latency factor: {h['receipt_verify_p99_ms']/c['receipt_verify_p99_ms']:.0f}x "
454
+ f"[implementation-bound: pure-Python ML-DSA]")