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 +45 -0
- ziffer-0.1.0/README.md +35 -0
- ziffer-0.1.0/pyproject.toml +29 -0
- ziffer-0.1.0/setup.cfg +4 -0
- ziffer-0.1.0/src/ziffer/__init__.py +30 -0
- ziffer-0.1.0/src/ziffer/_engine/__init__.py +1 -0
- ziffer-0.1.0/src/ziffer/_engine/acp_crypto.py +454 -0
- ziffer-0.1.0/src/ziffer/_engine/acp_executor.py +2086 -0
- ziffer-0.1.0/src/ziffer/_engineload.py +94 -0
- ziffer-0.1.0/src/ziffer/client.py +159 -0
- ziffer-0.1.0/src/ziffer/errors.py +81 -0
- ziffer-0.1.0/src/ziffer/verify.py +283 -0
- ziffer-0.1.0/src/ziffer.egg-info/PKG-INFO +45 -0
- ziffer-0.1.0/src/ziffer.egg-info/SOURCES.txt +17 -0
- ziffer-0.1.0/src/ziffer.egg-info/dependency_links.txt +1 -0
- ziffer-0.1.0/src/ziffer.egg-info/requires.txt +2 -0
- ziffer-0.1.0/src/ziffer.egg-info/top_level.txt +1 -0
- ziffer-0.1.0/tests/test_client.py +192 -0
- ziffer-0.1.0/tests/test_verify.py +246 -0
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,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]")
|