flux-sim-engine 0.2.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.
- flux_engine/__init__.py +22 -0
- flux_engine/_links.py +55 -0
- flux_engine/_vendor/__init__.py +5 -0
- flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
- flux_engine/_vendor/flux_spec/__init__.py +9 -0
- flux_engine/_vendor/flux_spec/bundle.py +410 -0
- flux_engine/_vendor/flux_spec/enforce.py +316 -0
- flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
- flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
- flux_engine/_vendor/flux_spec/validate.py +537 -0
- flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
- flux_engine/_vocabulary.py +141 -0
- flux_engine/accuracy/__init__.py +5 -0
- flux_engine/accuracy/_scoring/__init__.py +24 -0
- flux_engine/accuracy/accuracy_record.py +96 -0
- flux_engine/accuracy/policy.py +158 -0
- flux_engine/accuracy/records.py +264 -0
- flux_engine/accuracy/store.py +169 -0
- flux_engine/api/__init__.py +163 -0
- flux_engine/api/accuracy.py +209 -0
- flux_engine/api/agentic.py +134 -0
- flux_engine/api/calibration.py +125 -0
- flux_engine/api/claims.py +93 -0
- flux_engine/api/digests.py +73 -0
- flux_engine/api/emit.py +78 -0
- flux_engine/api/expressions.py +72 -0
- flux_engine/api/fidelity.py +110 -0
- flux_engine/api/lineage.py +78 -0
- flux_engine/api/stages.py +242 -0
- flux_engine/api/verdict.py +108 -0
- flux_engine/calibrate/__init__.py +5 -0
- flux_engine/calibrate/declared.py +253 -0
- flux_engine/calibrate/provenance.py +113 -0
- flux_engine/cli/__init__.py +2146 -0
- flux_engine/cli/__main__.py +17 -0
- flux_engine/cli/_documents.py +226 -0
- flux_engine/cli/_hints.py +118 -0
- flux_engine/cli/_init.py +152 -0
- flux_engine/cli/_progress.py +124 -0
- flux_engine/concordance/__init__.py +127 -0
- flux_engine/concordance/boundary/__init__.py +78 -0
- flux_engine/concordance/boundary/merkle.py +287 -0
- flux_engine/concordance/boundary/slice.py +429 -0
- flux_engine/concordance/boundary/statistic.py +561 -0
- flux_engine/concordance/boundary/store.py +334 -0
- flux_engine/concordance/boundary/vault.py +357 -0
- flux_engine/concordance/compiler.py +542 -0
- flux_engine/concordance/errors.py +189 -0
- flux_engine/concordance/gate.py +525 -0
- flux_engine/concordance/predicate.py +1217 -0
- flux_engine/concordance/record.py +554 -0
- flux_engine/concordance/verifier.py +583 -0
- flux_engine/core/__init__.py +5 -0
- flux_engine/core/artifact.py +99 -0
- flux_engine/core/canonical.py +369 -0
- flux_engine/core/clock.py +437 -0
- flux_engine/core/digest.py +288 -0
- flux_engine/core/errors.py +277 -0
- flux_engine/core/fixedpoint.py +929 -0
- flux_engine/core/lrm.py +158 -0
- flux_engine/core/rng.py +736 -0
- flux_engine/core/seedtree.py +891 -0
- flux_engine/core/trace.py +423 -0
- flux_engine/describe.py +207 -0
- flux_engine/engine.py +257 -0
- flux_engine/evidence/__init__.py +5 -0
- flux_engine/evidence/pack.py +411 -0
- flux_engine/evidence/report.py +243 -0
- flux_engine/expr/__init__.py +133 -0
- flux_engine/expr/ast.py +312 -0
- flux_engine/expr/attrs.py +386 -0
- flux_engine/expr/check.py +278 -0
- flux_engine/expr/compile.py +322 -0
- flux_engine/expr/errors.py +256 -0
- flux_engine/expr/lexer.py +297 -0
- flux_engine/expr/parser.py +313 -0
- flux_engine/expr/types.py +177 -0
- flux_engine/generator/__init__.py +97 -0
- flux_engine/generator/families.py +325 -0
- flux_engine/generator/paths.py +227 -0
- flux_engine/generator/rows.py +918 -0
- flux_engine/generator/statistics.py +524 -0
- flux_engine/governance/__init__.py +87 -0
- flux_engine/governance/audit.py +307 -0
- flux_engine/governance/gate.py +334 -0
- flux_engine/governance/skills.py +220 -0
- flux_engine/governance/sovereignty.py +251 -0
- flux_engine/lineage/__init__.py +5 -0
- flux_engine/lineage/emitter.py +398 -0
- flux_engine/py.typed +0 -0
- flux_engine/runtime/__init__.py +153 -0
- flux_engine/runtime/campaign.py +407 -0
- flux_engine/runtime/cast.py +283 -0
- flux_engine/runtime/detector.py +222 -0
- flux_engine/runtime/errors.py +58 -0
- flux_engine/runtime/experiment.py +311 -0
- flux_engine/runtime/journey.py +383 -0
- flux_engine/runtime/jurisdiction.py +72 -0
- flux_engine/runtime/payloads.py +124 -0
- flux_engine/runtime/signals.py +334 -0
- flux_engine/runtime/temperature.py +156 -0
- flux_engine/seam/__init__.py +5 -0
- flux_engine/seam/attest.py +290 -0
- flux_engine/seam/conform.py +448 -0
- flux_engine/seam/contract.py +605 -0
- flux_engine/seam/emit.py +488 -0
- flux_engine/seam/mapping.py +299 -0
- flux_engine/seam/resolve.py +1087 -0
- flux_engine/seam/sinks.py +157 -0
- flux_engine/spec/__init__.py +134 -0
- flux_engine/spec/bundle.py +25 -0
- flux_engine/spec/enforce.py +31 -0
- flux_engine/spec/validate.py +28 -0
- flux_engine/stages/__init__.py +30 -0
- flux_engine/stages/apply.py +447 -0
- flux_engine/stages/errors.py +315 -0
- flux_engine/stages/layout.py +292 -0
- flux_engine/stages/pipeline.py +600 -0
- flux_engine/stages/plan.py +241 -0
- flux_engine/stages/replay.py +83 -0
- flux_engine/stages/run.py +208 -0
- flux_engine/stages/ship.py +934 -0
- flux_engine/stages/validate.py +93 -0
- flux_engine/stages/verify.py +1129 -0
- flux_engine/starter/__init__.py +161 -0
- flux_engine/starter/bundle/detector.flux.yml +15 -0
- flux_engine/starter/bundle/journey.flux.yml +23 -0
- flux_engine/starter/bundle/module.flux.yml +16 -0
- flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
- flux_engine/starter/bundle/persona.flux.yml +26 -0
- flux_engine/starter/bundle/signal.flux.yml +13 -0
- flux_engine/starter/bundle/simulation.flux.yml +29 -0
- flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
- flux_engine/starter/bundle/treatment.flux.yml +13 -0
- flux_engine/starter/bundle/world.flux.yml +20 -0
- flux_engine/verticals/__init__.py +85 -0
- flux_engine/verticals/telco.py +89 -0
- flux_engine/world/__init__.py +89 -0
- flux_engine/world/derived.py +195 -0
- flux_engine/world/errors.py +41 -0
- flux_engine/world/families.py +260 -0
- flux_engine/world/population.py +602 -0
- flux_engine/world/tracing.py +66 -0
- flux_engine/world/values.py +121 -0
- flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
- flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
- flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
- flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
- flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
- flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
- flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# Copyright 2026 Agentics Transformation Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""The fidelity record -- SEAM-FIDELITY-SPEC §6.
|
|
6
|
+
|
|
7
|
+
One record per verified emit, in the evidence pack under
|
|
8
|
+
``run/fidelity-record-<exposeId>.json``. §6 fixes the shape; this module
|
|
9
|
+
builds it, signs it, and reads it back.
|
|
10
|
+
|
|
11
|
+
The scope statement is generated from a fixed template, never
|
|
12
|
+
hand-edited; its ``doesNotCertify`` list is the register-discipline
|
|
13
|
+
guard. The record is signed by the verifier key (ed25519 via
|
|
14
|
+
``cryptography``) [...].
|
|
15
|
+
|
|
16
|
+
Three choices worth stating.
|
|
17
|
+
|
|
18
|
+
**The scope statement is a constant, not a parameter.** :data:`SCOPE` is
|
|
19
|
+
built from :data:`SCOPE_CERTIFIES` and :data:`SCOPE_DOES_NOT_CERTIFY`,
|
|
20
|
+
which no function takes an argument for and no caller can override.
|
|
21
|
+
:func:`load_record` refuses a record whose scope statement is not that
|
|
22
|
+
one, so a record that quietly narrowed its own limits is unreadable rather
|
|
23
|
+
than persuasive.
|
|
24
|
+
|
|
25
|
+
**The signature covers everything except itself -- and that includes who
|
|
26
|
+
signed.** The signed bytes are the canonical JSON of the record with the
|
|
27
|
+
``signature`` field removed, under a domain tag, so a field added to the
|
|
28
|
+
record after signing invalidates the signature and the signed bytes
|
|
29
|
+
cannot be replayed as the preimage of anything else. The signer's own
|
|
30
|
+
identity -- key id, public key, whether the key was a per-invocation one
|
|
31
|
+
-- therefore lives in a top-level ``signer`` block (:func:`signer_block`)
|
|
32
|
+
*inside* the signed body, not in the excluded ``signature`` object. With
|
|
33
|
+
it outside, anyone could rewrite a record's body, sign it with a key of
|
|
34
|
+
their own, drop that key's public half in beside the signature, and
|
|
35
|
+
produce a document that verified against the only key travelling with it.
|
|
36
|
+
|
|
37
|
+
**A verifier checks against keys its caller trusts.** :func:`verify_record`
|
|
38
|
+
takes the trust anchors as an argument and reports a record that verifies
|
|
39
|
+
under none of them as unverified. It never takes the key out of the
|
|
40
|
+
record: a key a document carries about itself answers "is this document
|
|
41
|
+
internally consistent", not "who signed it".
|
|
42
|
+
|
|
43
|
+
**An unsigned record says it is unsigned.** ``cryptography`` is a declared
|
|
44
|
+
dependency, so :class:`Ed25519Signer` is the ordinary path. The fallback
|
|
45
|
+
exists for an environment without it, and it writes
|
|
46
|
+
``"alg": "unsigned-fallback"`` and an empty signature rather than omitting
|
|
47
|
+
the field: a reader must be able to tell an unsigned record from a signed
|
|
48
|
+
one at a glance, and an absent field would not tell them.
|
|
49
|
+
|
|
50
|
+
Nothing here reads a clock. The commitment time comes from the committed
|
|
51
|
+
state and the record carries no generation timestamp of its own -- the
|
|
52
|
+
evidence pack's envelope (``flux_engine.core.artifact``) is where a
|
|
53
|
+
wall-clock field belongs, and it is masked out of every digest.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import base64
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from types import MappingProxyType
|
|
61
|
+
from typing import (
|
|
62
|
+
Any,
|
|
63
|
+
Dict,
|
|
64
|
+
List,
|
|
65
|
+
Mapping,
|
|
66
|
+
Optional,
|
|
67
|
+
Protocol,
|
|
68
|
+
Sequence,
|
|
69
|
+
Tuple,
|
|
70
|
+
Union,
|
|
71
|
+
runtime_checkable,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
from flux_engine.concordance.boundary.slice import CommittedState
|
|
75
|
+
from flux_engine.concordance.compiler import CompiledFidelity
|
|
76
|
+
from flux_engine.concordance.errors import FLX_8002
|
|
77
|
+
from flux_engine.concordance.verifier import FidelityEvaluation
|
|
78
|
+
from flux_engine.core.canonical import canonical_bytes
|
|
79
|
+
from flux_engine.core.errors import FluxError
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"Ed25519Signer",
|
|
83
|
+
"FidelityRecord",
|
|
84
|
+
"RECORD_VERSION",
|
|
85
|
+
"SCOPE",
|
|
86
|
+
"SCOPE_CERTIFIES",
|
|
87
|
+
"SCOPE_DOES_NOT_CERTIFY",
|
|
88
|
+
"SIGNATURE_PREFIX",
|
|
89
|
+
"TrustedKeys",
|
|
90
|
+
"RecordSigner",
|
|
91
|
+
"SyntheticReference",
|
|
92
|
+
"UnsignedFallbackSigner",
|
|
93
|
+
"build_record",
|
|
94
|
+
"load_record",
|
|
95
|
+
"record_signer_public_key",
|
|
96
|
+
"signer_block",
|
|
97
|
+
"signing_payload",
|
|
98
|
+
"verify_record",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
#: What :func:`verify_record` accepts as a trust anchor: one raw ed25519
|
|
102
|
+
#: public key, several of them, or ``None`` for "the caller has none" --
|
|
103
|
+
#: which is a reason a record is unverified, never a reason to fall back
|
|
104
|
+
#: to the key the record names about itself.
|
|
105
|
+
TrustedKeys = Union[bytes, Sequence[bytes], None]
|
|
106
|
+
|
|
107
|
+
#: §6's ``recordVersion``.
|
|
108
|
+
RECORD_VERSION = 1
|
|
109
|
+
|
|
110
|
+
#: Domain tag on the signed bytes, so the preimage of a record signature
|
|
111
|
+
#: cannot be the preimage of anything else this engine signs.
|
|
112
|
+
SIGNATURE_PREFIX = b"flux-fidelity-record/1\x00"
|
|
113
|
+
|
|
114
|
+
#: §6's scope statement, verbatim. Not a template with slots: the whole
|
|
115
|
+
#: point of the field is that it says the same thing on every record.
|
|
116
|
+
SCOPE_CERTIFIES = (
|
|
117
|
+
"statistical relation between the synthetic dataset and the committed state "
|
|
118
|
+
"under the registered properties"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
#: §6's ``doesNotCertify`` list, verbatim and in order. It is the
|
|
122
|
+
#: register-discipline guard: every limit a reader might otherwise assume
|
|
123
|
+
#: away is written down where they will read it.
|
|
124
|
+
SCOPE_DOES_NOT_CERTIFY: Tuple[str, ...] = (
|
|
125
|
+
"privacy or re-identification resistance of the synthetic dataset",
|
|
126
|
+
"fairness or any property of the source dataset itself",
|
|
127
|
+
"any property of a system trained on or tested with the synthetic dataset",
|
|
128
|
+
"that the synthetic dataset was generated independently of the source",
|
|
129
|
+
"that the committed state is the whole source dataset",
|
|
130
|
+
"predictive validity of any simulation outcome",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
#: The generated scope statement, as it appears in every record.
|
|
134
|
+
SCOPE: Mapping[str, Any] = MappingProxyType(
|
|
135
|
+
{
|
|
136
|
+
"certifies": SCOPE_CERTIFIES,
|
|
137
|
+
"doesNotCertify": list(SCOPE_DOES_NOT_CERTIFY),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Signing
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@runtime_checkable
|
|
148
|
+
class RecordSigner(Protocol):
|
|
149
|
+
"""The signature port. One implementation signs; one states it did not."""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def alg(self) -> str:
|
|
153
|
+
"""The signature algorithm's wire name, written into the record."""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def key_id(self) -> str:
|
|
157
|
+
"""The id of the key the signature was taken with."""
|
|
158
|
+
|
|
159
|
+
def sign(self, payload: bytes) -> str:
|
|
160
|
+
"""Return the base64 signature over *payload*."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class Ed25519Signer:
|
|
165
|
+
"""§6's signer: ed25519 over the record's canonical bytes.
|
|
166
|
+
|
|
167
|
+
Takes the 32-byte raw private key rather than a key object so the
|
|
168
|
+
caller decides where key material comes from and this module never
|
|
169
|
+
touches a file, an environment variable or a key store.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
private_key: bytes
|
|
173
|
+
key_id: str
|
|
174
|
+
alg: str = "ed25519"
|
|
175
|
+
|
|
176
|
+
def __post_init__(self) -> None:
|
|
177
|
+
if len(self.private_key) != 32:
|
|
178
|
+
raise FluxError(FLX_8002, "an ed25519 private key is 32 raw bytes")
|
|
179
|
+
if not self.key_id.strip():
|
|
180
|
+
raise FluxError(FLX_8002, "a signature names the key it was taken with")
|
|
181
|
+
|
|
182
|
+
def sign(self, payload: bytes) -> str:
|
|
183
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
184
|
+
|
|
185
|
+
key = Ed25519PrivateKey.from_private_bytes(self.private_key)
|
|
186
|
+
return base64.b64encode(key.sign(payload)).decode("ascii")
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def public_key(self) -> bytes:
|
|
190
|
+
"""The 32-byte raw public key a verifier checks the signature with."""
|
|
191
|
+
from cryptography.hazmat.primitives import serialization
|
|
192
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
193
|
+
|
|
194
|
+
key = Ed25519PrivateKey.from_private_bytes(self.private_key)
|
|
195
|
+
return key.public_key().public_bytes(
|
|
196
|
+
encoding=serialization.Encoding.Raw,
|
|
197
|
+
format=serialization.PublicFormat.Raw,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class UnsignedFallbackSigner:
|
|
203
|
+
"""States that a record was not signed, rather than omitting the field.
|
|
204
|
+
|
|
205
|
+
Used where ``cryptography`` is unavailable. :func:`verify_record`
|
|
206
|
+
never accepts a record carrying this algorithm as verified -- it
|
|
207
|
+
reports it as unsigned -- so the fallback cannot be mistaken for a
|
|
208
|
+
weaker signature. It is a stated absence.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
key_id: str = "unsigned"
|
|
212
|
+
alg: str = "unsigned-fallback"
|
|
213
|
+
|
|
214
|
+
def sign(self, payload: bytes) -> str:
|
|
215
|
+
return ""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def signer_block(signer: RecordSigner, *, ephemeral: bool) -> Dict[str, Any]:
|
|
219
|
+
"""The record's top-level ``signer`` block: who took the signature.
|
|
220
|
+
|
|
221
|
+
Written into the document **before** it is signed, so it is covered by
|
|
222
|
+
the signature. ``publicKey`` is the signer's own 32-byte raw key as
|
|
223
|
+
hex when it has one (``None`` for the unsigned fallback), and
|
|
224
|
+
``ephemeral`` says whether that key was generated for one invocation
|
|
225
|
+
and belongs to nobody -- the difference between "this record is
|
|
226
|
+
attributable" and "these bytes are internally consistent".
|
|
227
|
+
"""
|
|
228
|
+
public_key = getattr(signer, "public_key", None)
|
|
229
|
+
return {
|
|
230
|
+
"alg": signer.alg,
|
|
231
|
+
"keyId": signer.key_id,
|
|
232
|
+
"publicKey": None if public_key is None else bytes(public_key).hex(),
|
|
233
|
+
"ephemeral": bool(ephemeral),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def record_signer_public_key(document: Mapping[str, Any]) -> Optional[bytes]:
|
|
238
|
+
"""The raw public key the record's ``signer`` block names, or ``None``.
|
|
239
|
+
|
|
240
|
+
A convenience for a reader that wants to check a record's internal
|
|
241
|
+
consistency. It is **not** a trust anchor: a record naming its own key
|
|
242
|
+
verifies against itself no matter who wrote it, which is exactly what
|
|
243
|
+
:func:`verify_record` refuses to accept as an answer.
|
|
244
|
+
"""
|
|
245
|
+
signer = document.get("signer")
|
|
246
|
+
if not isinstance(signer, Mapping):
|
|
247
|
+
return None
|
|
248
|
+
declared = signer.get("publicKey")
|
|
249
|
+
if not isinstance(declared, str):
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
return bytes.fromhex(declared)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def signing_payload(document: Mapping[str, Any]) -> bytes:
|
|
258
|
+
"""The bytes a record's signature is taken over.
|
|
259
|
+
|
|
260
|
+
The record's canonical JSON with ``signature`` removed, under
|
|
261
|
+
:data:`SIGNATURE_PREFIX`. Removing the field rather than blanking it
|
|
262
|
+
keeps the payload identical whether the record has been signed yet or
|
|
263
|
+
not, so signing and verifying hash the same bytes.
|
|
264
|
+
"""
|
|
265
|
+
body = {key: value for key, value in document.items() if key != "signature"}
|
|
266
|
+
return SIGNATURE_PREFIX + canonical_bytes(body)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# The record
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class SyntheticReference:
|
|
276
|
+
"""§6's ``synthetic`` block: what was checked, and what produced it."""
|
|
277
|
+
|
|
278
|
+
output_digest: str
|
|
279
|
+
rows: int
|
|
280
|
+
generator_digest: str
|
|
281
|
+
|
|
282
|
+
def to_document(self) -> Dict[str, Any]:
|
|
283
|
+
"""The canonical form."""
|
|
284
|
+
return {
|
|
285
|
+
"outputDigest": self.output_digest,
|
|
286
|
+
"rows": self.rows,
|
|
287
|
+
"generatorDigest": self.generator_digest,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class FidelityRecord:
|
|
293
|
+
"""One fidelity record: §6's document, and the signer that closed it."""
|
|
294
|
+
|
|
295
|
+
document: Mapping[str, Any]
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def h(self) -> str:
|
|
299
|
+
"""The seam digest the record is bound to."""
|
|
300
|
+
return str(self.document["H"])
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def g(self) -> str:
|
|
304
|
+
"""The digest of the declarations the committed statistic was formed under."""
|
|
305
|
+
return str(self.document["G"])
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def expose_id(self) -> str:
|
|
309
|
+
"""The emit this record answers for."""
|
|
310
|
+
return str(self.document["reference"]["exposeId"])
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def signed(self) -> bool:
|
|
314
|
+
"""Whether the record carries a real signature rather than a stated absence."""
|
|
315
|
+
signature = self.document.get("signature", {})
|
|
316
|
+
return bool(signature.get("sig")) and signature.get("alg") != UnsignedFallbackSigner.alg
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def outcomes(self) -> Tuple[str, ...]:
|
|
320
|
+
"""Each declared property's outcome, in declaration order."""
|
|
321
|
+
return tuple(str(row["outcome"]) for row in self.document["properties"])
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def within(self) -> bool:
|
|
325
|
+
"""Whether every property this record reports is within its margin."""
|
|
326
|
+
return all(outcome == "within" for outcome in self.outcomes)
|
|
327
|
+
|
|
328
|
+
def to_document(self) -> Dict[str, Any]:
|
|
329
|
+
"""A mutable copy of the record document."""
|
|
330
|
+
return dict(self.document)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def build_record(
|
|
334
|
+
*,
|
|
335
|
+
compiled: CompiledFidelity,
|
|
336
|
+
committed: CommittedState,
|
|
337
|
+
evaluation: FidelityEvaluation,
|
|
338
|
+
synthetic: SyntheticReference,
|
|
339
|
+
product_ref: str,
|
|
340
|
+
fluid_version: str,
|
|
341
|
+
contract_digest: str,
|
|
342
|
+
check_ordinal: int,
|
|
343
|
+
signer: RecordSigner,
|
|
344
|
+
ephemeral_key: bool = False,
|
|
345
|
+
proof_system: str = "none",
|
|
346
|
+
record_range: Optional[Sequence[int]] = None,
|
|
347
|
+
) -> FidelityRecord:
|
|
348
|
+
"""Build and sign one fidelity record (§6).
|
|
349
|
+
|
|
350
|
+
Refuses (``FLX-8002``) to write a record for an evaluation that met a
|
|
351
|
+
fail-closed condition: §5 says the check declines in those cases, and
|
|
352
|
+
a record is the artefact that says a check *ran*. An evaluation whose
|
|
353
|
+
properties are outside their margins does get a record -- that is a
|
|
354
|
+
result, and the gate is what withholds the bundle over it.
|
|
355
|
+
|
|
356
|
+
``recordCount`` and ``recordRange`` are §6's alternatives. The count
|
|
357
|
+
is written by default; pass *record_range* (a two-element ``[min,
|
|
358
|
+
max]``) when the owner would rather publish the declared range than
|
|
359
|
+
the exact number, which §4 allows.
|
|
360
|
+
"""
|
|
361
|
+
if evaluation.refused:
|
|
362
|
+
raise FluxError(
|
|
363
|
+
FLX_8002,
|
|
364
|
+
f"the fidelity check met {len(evaluation.refusals)} fail-closed conditions; "
|
|
365
|
+
"there is no record to write",
|
|
366
|
+
)
|
|
367
|
+
if evaluation.expose_id != compiled.block.expose_id:
|
|
368
|
+
raise FluxError(FLX_8002, "the evaluation answers for a different expose")
|
|
369
|
+
if check_ordinal < 1:
|
|
370
|
+
raise FluxError(FLX_8002, "a checkOrdinal counts from one")
|
|
371
|
+
|
|
372
|
+
committed_block: Dict[str, Any] = {
|
|
373
|
+
"ordinal": committed.ordinal,
|
|
374
|
+
"C1": [entry.to_document() for entry in committed.c1],
|
|
375
|
+
"C2": committed.c2,
|
|
376
|
+
"committedAt": committed.committed_at,
|
|
377
|
+
"checkOrdinal": check_ordinal,
|
|
378
|
+
}
|
|
379
|
+
if record_range is None:
|
|
380
|
+
committed_block["recordCount"] = committed.record_count
|
|
381
|
+
else:
|
|
382
|
+
bounds = list(record_range)
|
|
383
|
+
if len(bounds) != 2 or bounds[0] > bounds[1]:
|
|
384
|
+
raise FluxError(FLX_8002, "a recordRange is an ascending pair")
|
|
385
|
+
committed_block["recordRange"] = bounds
|
|
386
|
+
|
|
387
|
+
document: Dict[str, Any] = {
|
|
388
|
+
"recordVersion": RECORD_VERSION,
|
|
389
|
+
"reference": {
|
|
390
|
+
"productRef": product_ref,
|
|
391
|
+
"exposeId": compiled.block.expose_id,
|
|
392
|
+
"fluidVersion": fluid_version,
|
|
393
|
+
"contractDigest": contract_digest,
|
|
394
|
+
},
|
|
395
|
+
"H": compiled.h.value,
|
|
396
|
+
"G": compiled.g.value,
|
|
397
|
+
"committedState": committed_block,
|
|
398
|
+
"synthetic": synthetic.to_document(),
|
|
399
|
+
"properties": [outcome.to_record_row() for outcome in evaluation.outcomes],
|
|
400
|
+
"proofSystem": proof_system,
|
|
401
|
+
"scope": {
|
|
402
|
+
"certifies": SCOPE_CERTIFIES,
|
|
403
|
+
"doesNotCertify": list(SCOPE_DOES_NOT_CERTIFY),
|
|
404
|
+
},
|
|
405
|
+
"columnsCovered": list(compiled.block.columns_covered),
|
|
406
|
+
# Inside the signed body, never beside the signature: see this
|
|
407
|
+
# module's docstring. *ephemeral_key* is the caller's statement
|
|
408
|
+
# about where the key material came from -- this module never
|
|
409
|
+
# generates one, so it cannot work that out for itself.
|
|
410
|
+
"signer": signer_block(signer, ephemeral=ephemeral_key),
|
|
411
|
+
}
|
|
412
|
+
document["signature"] = {
|
|
413
|
+
"alg": signer.alg,
|
|
414
|
+
"keyId": signer.key_id,
|
|
415
|
+
"sig": signer.sign(signing_payload(document)),
|
|
416
|
+
}
|
|
417
|
+
return FidelityRecord(document=MappingProxyType(document))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
#: Every key §6 fixes at the top level of a record.
|
|
421
|
+
_REQUIRED_KEYS = (
|
|
422
|
+
"H",
|
|
423
|
+
"G",
|
|
424
|
+
"columnsCovered",
|
|
425
|
+
"committedState",
|
|
426
|
+
"properties",
|
|
427
|
+
"proofSystem",
|
|
428
|
+
"recordVersion",
|
|
429
|
+
"reference",
|
|
430
|
+
"scope",
|
|
431
|
+
"signature",
|
|
432
|
+
"signer",
|
|
433
|
+
"synthetic",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def load_record(document: Mapping[str, Any]) -> FidelityRecord:
|
|
438
|
+
"""Read a record back, refusing (``FLX-8002``) one that is not §6's shape.
|
|
439
|
+
|
|
440
|
+
Checks the version, the top-level keys, and -- the one check that is
|
|
441
|
+
not merely structural -- that the scope statement is the generated
|
|
442
|
+
one. A record whose ``doesNotCertify`` list has been shortened is not
|
|
443
|
+
a record with a smaller list; it is a different document wearing this
|
|
444
|
+
one's name.
|
|
445
|
+
"""
|
|
446
|
+
if not isinstance(document, Mapping):
|
|
447
|
+
raise FluxError(FLX_8002, f"a record is a mapping, got {type(document).__name__}")
|
|
448
|
+
missing = [key for key in _REQUIRED_KEYS if key not in document]
|
|
449
|
+
if missing:
|
|
450
|
+
raise FluxError(FLX_8002, f"the record is missing {', '.join(missing)}")
|
|
451
|
+
if document["recordVersion"] != RECORD_VERSION:
|
|
452
|
+
raise FluxError(
|
|
453
|
+
FLX_8002,
|
|
454
|
+
f"recordVersion {document['recordVersion']!r} is not {RECORD_VERSION}",
|
|
455
|
+
)
|
|
456
|
+
scope = document["scope"]
|
|
457
|
+
if not isinstance(scope, Mapping):
|
|
458
|
+
raise FluxError(FLX_8002, "the record's scope statement is not a mapping")
|
|
459
|
+
if scope.get("certifies") != SCOPE_CERTIFIES:
|
|
460
|
+
raise FluxError(FLX_8002, "the record's scope statement is not the generated one")
|
|
461
|
+
if tuple(scope.get("doesNotCertify", ())) != SCOPE_DOES_NOT_CERTIFY:
|
|
462
|
+
raise FluxError(
|
|
463
|
+
FLX_8002,
|
|
464
|
+
"the record's scope statement has a different list of what it does not cover",
|
|
465
|
+
)
|
|
466
|
+
properties = document["properties"]
|
|
467
|
+
if not isinstance(properties, Sequence) or isinstance(properties, (str, bytes)):
|
|
468
|
+
raise FluxError(FLX_8002, "the record's properties are not a list")
|
|
469
|
+
for index, row in enumerate(properties):
|
|
470
|
+
if not isinstance(row, Mapping) or row.get("outcome") not in ("within", "outside"):
|
|
471
|
+
raise FluxError(FLX_8002, f"the record's property row {index} has no outcome")
|
|
472
|
+
return FidelityRecord(document=MappingProxyType(dict(document)))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _trust_anchors(trusted_keys: TrustedKeys) -> Tuple[Tuple[bytes, ...], List[str]]:
|
|
476
|
+
"""Normalise *trusted_keys* to raw 32-byte keys, plus the reasons any were refused."""
|
|
477
|
+
if trusted_keys is None:
|
|
478
|
+
return (), []
|
|
479
|
+
candidates: Sequence[bytes]
|
|
480
|
+
if isinstance(trusted_keys, (bytes, bytearray)):
|
|
481
|
+
candidates = (bytes(trusted_keys),)
|
|
482
|
+
else:
|
|
483
|
+
candidates = [bytes(key) for key in trusted_keys]
|
|
484
|
+
anchors = tuple(key for key in candidates if len(key) == 32)
|
|
485
|
+
reasons = ["an ed25519 public key is 32 raw bytes"] if len(anchors) != len(candidates) else []
|
|
486
|
+
return anchors, reasons
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def verify_record(document: Mapping[str, Any], trusted_keys: TrustedKeys) -> List[str]:
|
|
490
|
+
"""Return the reasons *document* is not a verified record; empty means it is.
|
|
491
|
+
|
|
492
|
+
*trusted_keys* is the caller's trust anchor -- one raw ed25519 public
|
|
493
|
+
key or a sequence of them -- and the signature must verify under one
|
|
494
|
+
of them. It is deliberately not read out of the record: a key a
|
|
495
|
+
document carries about itself makes every self-consistent document
|
|
496
|
+
verify, including one an attacker rewrote and re-signed with a key of
|
|
497
|
+
their own. The record's own ``signer`` block is covered by the
|
|
498
|
+
signature, so it says who the *signer claimed* to be; whether that
|
|
499
|
+
signer is anyone the reader trusts is this argument's question.
|
|
500
|
+
|
|
501
|
+
A list rather than a boolean, for the same reason
|
|
502
|
+
:class:`~flux_engine.concordance.verifier.FidelityEvaluation` keeps
|
|
503
|
+
its refusals: the gate reports every reason it withheld a bundle, not
|
|
504
|
+
the first one it found. An unsigned-fallback record and a record whose
|
|
505
|
+
signature does not verify are two different reasons and say so.
|
|
506
|
+
"""
|
|
507
|
+
reasons: List[str] = []
|
|
508
|
+
try:
|
|
509
|
+
record = load_record(document)
|
|
510
|
+
except FluxError as error:
|
|
511
|
+
return [error.detail or error.code.title]
|
|
512
|
+
|
|
513
|
+
signature = record.document.get("signature", {})
|
|
514
|
+
if not isinstance(signature, Mapping):
|
|
515
|
+
return ["the record's signature block is not a mapping"]
|
|
516
|
+
signer = record.document.get("signer", {})
|
|
517
|
+
if not isinstance(signer, Mapping):
|
|
518
|
+
return ["the record's signer block is not a mapping"]
|
|
519
|
+
alg = signature.get("alg")
|
|
520
|
+
if alg == UnsignedFallbackSigner.alg:
|
|
521
|
+
return ["the record states that it was not signed"]
|
|
522
|
+
if alg != "ed25519":
|
|
523
|
+
return [f"the record's signature algorithm {alg!r} is not one this verifier reads"]
|
|
524
|
+
if signer.get("alg") != alg or signer.get("keyId") != signature.get("keyId"):
|
|
525
|
+
return ["the record's signer block and its signature name different keys"]
|
|
526
|
+
|
|
527
|
+
anchors, anchor_reasons = _trust_anchors(trusted_keys)
|
|
528
|
+
reasons.extend(anchor_reasons)
|
|
529
|
+
if not anchors:
|
|
530
|
+
return [
|
|
531
|
+
*reasons,
|
|
532
|
+
"no trusted key was supplied to check the record's signature against",
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
raw = base64.b64decode(str(signature.get("sig", "")), validate=True)
|
|
537
|
+
except (ValueError, TypeError):
|
|
538
|
+
return [*reasons, "the record's signature is not base64"]
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
from cryptography.exceptions import InvalidSignature
|
|
542
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
543
|
+
except ImportError: # pragma: no cover - cryptography is a declared dependency
|
|
544
|
+
return [*reasons, "cryptography is not installed, so the signature cannot be checked"]
|
|
545
|
+
|
|
546
|
+
payload = signing_payload(record.document)
|
|
547
|
+
for anchor in anchors:
|
|
548
|
+
try:
|
|
549
|
+
Ed25519PublicKey.from_public_bytes(anchor).verify(raw, payload)
|
|
550
|
+
except InvalidSignature:
|
|
551
|
+
continue
|
|
552
|
+
return reasons
|
|
553
|
+
reasons.append("the record's signature does not verify against any trusted key")
|
|
554
|
+
return reasons
|