flowspec2 1.0.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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Internal detached Ed25519 signing and verification primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
14
|
+
Ed25519PrivateKey,
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ED25519_SIGNATURE_BYTES: Final[int] = 64
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DetachedEd25519KeyMismatchError(ValueError):
|
|
22
|
+
"""A detached signature names a different trusted public key."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DetachedEd25519VerificationError(ValueError):
|
|
26
|
+
"""A detached signature does not authenticate its message."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class DetachedEd25519Signature:
|
|
31
|
+
"""Canonical signature material produced by one Ed25519 private key."""
|
|
32
|
+
|
|
33
|
+
key_id: str
|
|
34
|
+
signature_base64: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_ed25519_private_key(private_key_pem: bytes) -> Ed25519PrivateKey:
|
|
38
|
+
"""Load one unencrypted PKCS8 Ed25519 private key."""
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
|
|
42
|
+
except (TypeError, UnsupportedAlgorithm, ValueError) as key_error:
|
|
43
|
+
raise ValueError("private key must be unencrypted PKCS8 PEM") from key_error
|
|
44
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
45
|
+
raise ValueError("private key must use Ed25519")
|
|
46
|
+
return private_key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_ed25519_public_key(public_key_pem: bytes) -> Ed25519PublicKey:
|
|
50
|
+
"""Load one SubjectPublicKeyInfo Ed25519 public key."""
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
public_key = serialization.load_pem_public_key(public_key_pem)
|
|
54
|
+
except (TypeError, UnsupportedAlgorithm, ValueError) as key_error:
|
|
55
|
+
raise ValueError("public key must be SubjectPublicKeyInfo PEM") from key_error
|
|
56
|
+
if not isinstance(public_key, Ed25519PublicKey):
|
|
57
|
+
raise ValueError("public key must use Ed25519")
|
|
58
|
+
return public_key
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ed25519_public_key_id(public_key: Ed25519PublicKey) -> str:
|
|
62
|
+
"""Return the lowercase SHA-256 identifier of a raw Ed25519 public key."""
|
|
63
|
+
|
|
64
|
+
public_key_bytes = public_key.public_bytes(
|
|
65
|
+
encoding=serialization.Encoding.Raw,
|
|
66
|
+
format=serialization.PublicFormat.Raw,
|
|
67
|
+
)
|
|
68
|
+
return hashlib.sha256(public_key_bytes).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def canonical_ed25519_signature_bytes(signature_base64: str) -> bytes:
|
|
72
|
+
"""Decode and validate one canonical base64 Ed25519 signature."""
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
signature_bytes = base64.b64decode(signature_base64, validate=True)
|
|
76
|
+
except (binascii.Error, ValueError) as decoding_error:
|
|
77
|
+
raise ValueError("signature must be canonical base64") from decoding_error
|
|
78
|
+
if (
|
|
79
|
+
len(signature_bytes) != ED25519_SIGNATURE_BYTES
|
|
80
|
+
or base64.b64encode(signature_bytes).decode("ascii") != signature_base64
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("signature must be a canonical Ed25519 signature")
|
|
83
|
+
return signature_bytes
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sign_detached_ed25519(
|
|
87
|
+
message: bytes,
|
|
88
|
+
private_key_pem: bytes,
|
|
89
|
+
) -> DetachedEd25519Signature:
|
|
90
|
+
"""Sign bytes and return canonical detached Ed25519 signature material."""
|
|
91
|
+
|
|
92
|
+
private_key = load_ed25519_private_key(private_key_pem)
|
|
93
|
+
signature_bytes = private_key.sign(message)
|
|
94
|
+
return DetachedEd25519Signature(
|
|
95
|
+
key_id=ed25519_public_key_id(private_key.public_key()),
|
|
96
|
+
signature_base64=base64.b64encode(signature_bytes).decode("ascii"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def verify_detached_ed25519(
|
|
101
|
+
message: bytes,
|
|
102
|
+
signature_base64: str,
|
|
103
|
+
public_key_pem: bytes,
|
|
104
|
+
*,
|
|
105
|
+
expected_key_id: str,
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Authenticate bytes with the named trusted Ed25519 public key."""
|
|
108
|
+
|
|
109
|
+
signature_bytes = canonical_ed25519_signature_bytes(signature_base64)
|
|
110
|
+
public_key = load_ed25519_public_key(public_key_pem)
|
|
111
|
+
public_key_id = ed25519_public_key_id(public_key)
|
|
112
|
+
if public_key_id != expected_key_id:
|
|
113
|
+
raise DetachedEd25519KeyMismatchError("detached signature names a different public key")
|
|
114
|
+
try:
|
|
115
|
+
public_key.verify(signature_bytes, message)
|
|
116
|
+
except InvalidSignature as signature_error:
|
|
117
|
+
raise DetachedEd25519VerificationError(
|
|
118
|
+
"detached Ed25519 signature is invalid"
|
|
119
|
+
) from signature_error
|
|
120
|
+
return public_key_id
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Content-addressed evidence for externally authored benchmark runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from flowspec2.json_codec import strict_json_loads, validate_json_value
|
|
13
|
+
|
|
14
|
+
from .benchmark import (
|
|
15
|
+
AuthoredSource,
|
|
16
|
+
AuthoringBenchmarkLimits,
|
|
17
|
+
AuthoringBenchmarkReport,
|
|
18
|
+
AuthoringRequest,
|
|
19
|
+
)
|
|
20
|
+
from .corpus import AuthoringCorpus
|
|
21
|
+
|
|
22
|
+
AUTHORING_EVIDENCE_FORMAT: Final[str] = "flowspec2/authoring-benchmark-evidence@3"
|
|
23
|
+
SOURCE_ADAPTER_CONTRACT: Final[dict[str, str]] = {
|
|
24
|
+
"format": "flowspec/2",
|
|
25
|
+
"implementation": "FlowSpec2JsonAdapter",
|
|
26
|
+
"version": "1",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_IDENTIFIER_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
30
|
+
_DIGEST_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$")
|
|
31
|
+
_SECRET_KEY_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
32
|
+
r"(?:api[_-]?key|authorization|credential|password|secret|access[_-]?token)",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canonical_json(json_document: object) -> str:
|
|
38
|
+
return json.dumps(
|
|
39
|
+
json_document,
|
|
40
|
+
ensure_ascii=False,
|
|
41
|
+
allow_nan=False,
|
|
42
|
+
separators=(",", ":"),
|
|
43
|
+
sort_keys=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sha256(serialized_contract: str) -> str:
|
|
48
|
+
return hashlib.sha256(serialized_contract.encode("utf-8")).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _contains_secret_key(json_document: object) -> bool:
|
|
52
|
+
if isinstance(json_document, Mapping):
|
|
53
|
+
return any(
|
|
54
|
+
_SECRET_KEY_PATTERN.search(str(property_name)) is not None
|
|
55
|
+
or _contains_secret_key(property_value)
|
|
56
|
+
for property_name, property_value in json_document.items()
|
|
57
|
+
)
|
|
58
|
+
if isinstance(json_document, list):
|
|
59
|
+
return any(_contains_secret_key(element_value) for element_value in json_document)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AuthoringCapture:
|
|
65
|
+
"""The exact authored source returned for one benchmark attempt."""
|
|
66
|
+
|
|
67
|
+
case_identifier: str
|
|
68
|
+
correction_round: int
|
|
69
|
+
authored_source: str
|
|
70
|
+
source_sha256: str
|
|
71
|
+
effective_model_version: str | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not _IDENTIFIER_PATTERN.fullmatch(self.case_identifier):
|
|
75
|
+
raise ValueError("authoring capture case identifier is invalid")
|
|
76
|
+
if self.correction_round < 0:
|
|
77
|
+
raise ValueError("authoring capture correction round must not be negative")
|
|
78
|
+
if self.source_sha256 != _sha256(self.authored_source):
|
|
79
|
+
raise ValueError("authoring capture source digest does not match its source")
|
|
80
|
+
if self.effective_model_version is not None:
|
|
81
|
+
normalized_model_version = self.effective_model_version.strip()
|
|
82
|
+
if not normalized_model_version:
|
|
83
|
+
raise ValueError("authoring capture model version must be non-empty when present")
|
|
84
|
+
object.__setattr__(self, "effective_model_version", normalized_model_version)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_response(
|
|
88
|
+
cls,
|
|
89
|
+
case_identifier: str,
|
|
90
|
+
correction_round: int,
|
|
91
|
+
authored_response: AuthoredSource,
|
|
92
|
+
) -> AuthoringCapture:
|
|
93
|
+
"""Capture one response with its deterministic content identity."""
|
|
94
|
+
|
|
95
|
+
return cls(
|
|
96
|
+
case_identifier=case_identifier,
|
|
97
|
+
correction_round=correction_round,
|
|
98
|
+
authored_source=authored_response.source,
|
|
99
|
+
source_sha256=_sha256(authored_response.source),
|
|
100
|
+
effective_model_version=authored_response.effective_model_version,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, object]:
|
|
104
|
+
return {
|
|
105
|
+
"case_identifier": self.case_identifier,
|
|
106
|
+
"correction_round": self.correction_round,
|
|
107
|
+
"authored_source": self.authored_source,
|
|
108
|
+
"source_sha256": self.source_sha256,
|
|
109
|
+
"effective_model_version": self.effective_model_version,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RecordingAuthor:
|
|
114
|
+
"""Record exact model sources while preserving the benchmark author protocol."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, author: Callable[[AuthoringRequest], AuthoredSource]) -> None:
|
|
117
|
+
self._author = author
|
|
118
|
+
self._captures: list[AuthoringCapture] = []
|
|
119
|
+
self._capture_keys: set[tuple[str, int]] = set()
|
|
120
|
+
|
|
121
|
+
def __call__(self, authoring_request: AuthoringRequest) -> AuthoredSource:
|
|
122
|
+
authored_response = self._author(authoring_request)
|
|
123
|
+
if not isinstance(authored_response, AuthoredSource):
|
|
124
|
+
raise TypeError("the recorded author must return AuthoredSource")
|
|
125
|
+
capture_key = (
|
|
126
|
+
authoring_request.task.identifier,
|
|
127
|
+
authoring_request.correction_round,
|
|
128
|
+
)
|
|
129
|
+
if capture_key in self._capture_keys:
|
|
130
|
+
raise ValueError(f"duplicate authoring capture: {capture_key!r}")
|
|
131
|
+
self._capture_keys.add(capture_key)
|
|
132
|
+
self._captures.append(
|
|
133
|
+
AuthoringCapture.from_response(
|
|
134
|
+
capture_key[0],
|
|
135
|
+
capture_key[1],
|
|
136
|
+
authored_response,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return authored_response
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def captures(self) -> tuple[AuthoringCapture, ...]:
|
|
143
|
+
return tuple(self._captures)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class AuthoringProviderProvenance:
|
|
148
|
+
"""Closed non-secret configuration for the model author boundary."""
|
|
149
|
+
|
|
150
|
+
identifier: str
|
|
151
|
+
model: str
|
|
152
|
+
sdk: str
|
|
153
|
+
sdk_version: str
|
|
154
|
+
prompt_format: str
|
|
155
|
+
prompt_digest: str
|
|
156
|
+
generation_configuration_json: str
|
|
157
|
+
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
if not _IDENTIFIER_PATTERN.fullmatch(self.identifier):
|
|
160
|
+
raise ValueError("authoring provider identifier is invalid")
|
|
161
|
+
for field_name, field_value in (
|
|
162
|
+
("model", self.model),
|
|
163
|
+
("sdk", self.sdk),
|
|
164
|
+
("sdk_version", self.sdk_version),
|
|
165
|
+
("prompt_format", self.prompt_format),
|
|
166
|
+
):
|
|
167
|
+
if not field_value.strip():
|
|
168
|
+
raise ValueError(f"authoring provider {field_name} must be non-empty")
|
|
169
|
+
if not _DIGEST_PATTERN.fullmatch(self.prompt_digest):
|
|
170
|
+
raise ValueError("authoring provider prompt digest must be lowercase SHA-256")
|
|
171
|
+
generation_configuration = strict_json_loads(self.generation_configuration_json)
|
|
172
|
+
if not isinstance(generation_configuration, dict):
|
|
173
|
+
raise ValueError("authoring provider generation configuration must be an object")
|
|
174
|
+
validate_json_value(
|
|
175
|
+
generation_configuration,
|
|
176
|
+
boundary="authoring provider generation configuration",
|
|
177
|
+
)
|
|
178
|
+
if _canonical_json(generation_configuration) != self.generation_configuration_json:
|
|
179
|
+
raise ValueError("authoring provider generation configuration must be canonical JSON")
|
|
180
|
+
if _contains_secret_key(generation_configuration):
|
|
181
|
+
raise ValueError("authoring provider generation configuration contains a secret field")
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_configuration(
|
|
185
|
+
cls,
|
|
186
|
+
*,
|
|
187
|
+
identifier: str,
|
|
188
|
+
model: str,
|
|
189
|
+
sdk: str,
|
|
190
|
+
sdk_version: str,
|
|
191
|
+
prompt_format: str,
|
|
192
|
+
prompt_digest: str,
|
|
193
|
+
generation_configuration: Mapping[str, object],
|
|
194
|
+
) -> AuthoringProviderProvenance:
|
|
195
|
+
return cls(
|
|
196
|
+
identifier=identifier,
|
|
197
|
+
model=model,
|
|
198
|
+
sdk=sdk,
|
|
199
|
+
sdk_version=sdk_version,
|
|
200
|
+
prompt_format=prompt_format,
|
|
201
|
+
prompt_digest=prompt_digest,
|
|
202
|
+
generation_configuration_json=_canonical_json(dict(generation_configuration)),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def to_dict(self) -> dict[str, object]:
|
|
206
|
+
return {
|
|
207
|
+
"identifier": self.identifier,
|
|
208
|
+
"model": self.model,
|
|
209
|
+
"sdk": self.sdk,
|
|
210
|
+
"sdk_version": self.sdk_version,
|
|
211
|
+
"prompt_format": self.prompt_format,
|
|
212
|
+
"prompt_digest": self.prompt_digest,
|
|
213
|
+
"generation_configuration": json.loads(self.generation_configuration_json),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class AuthoringBenchmarkEvidence:
|
|
219
|
+
"""Canonical report, captures, and provenance for one completed run."""
|
|
220
|
+
|
|
221
|
+
package_version: str
|
|
222
|
+
repository_revision: str
|
|
223
|
+
benchmark_limits: AuthoringBenchmarkLimits
|
|
224
|
+
corpus: AuthoringCorpus
|
|
225
|
+
profile_identifier: str
|
|
226
|
+
profile_digest: str
|
|
227
|
+
provider: AuthoringProviderProvenance
|
|
228
|
+
report: AuthoringBenchmarkReport
|
|
229
|
+
captures: tuple[AuthoringCapture, ...]
|
|
230
|
+
|
|
231
|
+
def __post_init__(self) -> None:
|
|
232
|
+
if not self.package_version.strip():
|
|
233
|
+
raise ValueError("authoring evidence package version must be non-empty")
|
|
234
|
+
if not self.repository_revision.strip():
|
|
235
|
+
raise ValueError("authoring evidence repository revision must be non-empty")
|
|
236
|
+
if self.profile_identifier != self.report.profile_identifier:
|
|
237
|
+
raise ValueError("authoring evidence profile identifier disagrees with report")
|
|
238
|
+
if any(
|
|
239
|
+
case_result.correction_rounds > self.benchmark_limits.max_correction_rounds
|
|
240
|
+
for case_result in self.report.case_results
|
|
241
|
+
):
|
|
242
|
+
raise ValueError("authoring evidence report exceeds its correction-round limit")
|
|
243
|
+
if not _DIGEST_PATTERN.fullmatch(self.profile_digest):
|
|
244
|
+
raise ValueError("authoring evidence profile digest must be lowercase SHA-256")
|
|
245
|
+
report_case_identifiers = tuple(
|
|
246
|
+
case_result.case_identifier for case_result in self.report.case_results
|
|
247
|
+
)
|
|
248
|
+
corpus_case_identifiers = tuple(
|
|
249
|
+
benchmark_case.identifier for benchmark_case in self.corpus.cases
|
|
250
|
+
)
|
|
251
|
+
if report_case_identifiers != corpus_case_identifiers:
|
|
252
|
+
raise ValueError("authoring evidence report cases disagree with corpus")
|
|
253
|
+
|
|
254
|
+
expected_attempts = tuple(
|
|
255
|
+
(case_result.case_identifier, authoring_attempt)
|
|
256
|
+
for case_result in self.report.case_results
|
|
257
|
+
for authoring_attempt in case_result.attempts
|
|
258
|
+
)
|
|
259
|
+
actual_capture_keys = tuple(
|
|
260
|
+
(capture.case_identifier, capture.correction_round) for capture in self.captures
|
|
261
|
+
)
|
|
262
|
+
expected_capture_keys = tuple(
|
|
263
|
+
(case_identifier, authoring_attempt.correction_round)
|
|
264
|
+
for case_identifier, authoring_attempt in expected_attempts
|
|
265
|
+
)
|
|
266
|
+
if actual_capture_keys != expected_capture_keys:
|
|
267
|
+
raise ValueError("authoring evidence captures do not align with report attempts")
|
|
268
|
+
if any(
|
|
269
|
+
capture.source_sha256 != authoring_attempt.source_sha256
|
|
270
|
+
for capture, (_case_identifier, authoring_attempt) in zip(
|
|
271
|
+
self.captures,
|
|
272
|
+
expected_attempts,
|
|
273
|
+
strict=True,
|
|
274
|
+
)
|
|
275
|
+
):
|
|
276
|
+
raise ValueError("authoring evidence capture digest disagrees with report")
|
|
277
|
+
if self.provider.identifier == "google_gemini" and any(
|
|
278
|
+
capture.effective_model_version is None for capture in self.captures
|
|
279
|
+
):
|
|
280
|
+
raise ValueError("Gemini authoring evidence requires effective model versions")
|
|
281
|
+
|
|
282
|
+
def _unsigned_dict(self) -> dict[str, object]:
|
|
283
|
+
return {
|
|
284
|
+
"format": AUTHORING_EVIDENCE_FORMAT,
|
|
285
|
+
"package_version": self.package_version,
|
|
286
|
+
"repository_revision": self.repository_revision,
|
|
287
|
+
"benchmark": {
|
|
288
|
+
"max_correction_rounds": self.benchmark_limits.max_correction_rounds,
|
|
289
|
+
},
|
|
290
|
+
"corpus": self.corpus.metadata(),
|
|
291
|
+
"profile": {
|
|
292
|
+
"identifier": self.profile_identifier,
|
|
293
|
+
"digest": self.profile_digest,
|
|
294
|
+
},
|
|
295
|
+
"source_adapter": dict(SOURCE_ADAPTER_CONTRACT),
|
|
296
|
+
"provider": self.provider.to_dict(),
|
|
297
|
+
"captures": [capture.to_dict() for capture in self.captures],
|
|
298
|
+
"report": self.report.to_dict(),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def digest(self) -> str:
|
|
303
|
+
return _sha256(_canonical_json(self._unsigned_dict()))
|
|
304
|
+
|
|
305
|
+
def to_dict(self) -> dict[str, object]:
|
|
306
|
+
evidence_document = self._unsigned_dict()
|
|
307
|
+
evidence_document["digest"] = self.digest
|
|
308
|
+
return evidence_document
|
|
309
|
+
|
|
310
|
+
def to_json(self) -> str:
|
|
311
|
+
return _canonical_json(self.to_dict())
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Detached Ed25519 authentication for verified authoring evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Final, cast
|
|
11
|
+
|
|
12
|
+
import jsonschema
|
|
13
|
+
|
|
14
|
+
from flowspec2.json_codec import strict_json_loads
|
|
15
|
+
|
|
16
|
+
from .detached_signature import (
|
|
17
|
+
DetachedEd25519KeyMismatchError,
|
|
18
|
+
DetachedEd25519VerificationError,
|
|
19
|
+
canonical_ed25519_signature_bytes,
|
|
20
|
+
sign_detached_ed25519,
|
|
21
|
+
verify_detached_ed25519,
|
|
22
|
+
)
|
|
23
|
+
from .evidence_verification import (
|
|
24
|
+
AuthoringEvidenceVerification,
|
|
25
|
+
verify_authoring_evidence,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
AUTHORING_EVIDENCE_SIGNATURE_FORMAT: Final[str] = "flowspec2/authoring-evidence-signature@1"
|
|
29
|
+
AUTHORING_EVIDENCE_SIGNATURE_ALGORITHM: Final[str] = "ed25519"
|
|
30
|
+
_SIGNATURE_SCHEMA_PATH: Final[Path] = Path(__file__).with_name(
|
|
31
|
+
"authoring-evidence-signature.schema.json"
|
|
32
|
+
)
|
|
33
|
+
_DIGEST_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$")
|
|
34
|
+
_SIGNATURE_MESSAGE_PREFIX: Final[bytes] = b"flowspec2/authoring-evidence-signature@1\x00ed25519\x00"
|
|
35
|
+
_signature_schema_cache: dict[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _canonical_json(json_document: object) -> str:
|
|
39
|
+
return json.dumps(
|
|
40
|
+
json_document,
|
|
41
|
+
ensure_ascii=False,
|
|
42
|
+
allow_nan=False,
|
|
43
|
+
separators=(",", ":"),
|
|
44
|
+
sort_keys=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _signature_schema() -> dict[str, Any]:
|
|
49
|
+
global _signature_schema_cache
|
|
50
|
+
cached_schema = _signature_schema_cache
|
|
51
|
+
if cached_schema is None:
|
|
52
|
+
decoded_schema = strict_json_loads(_SIGNATURE_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
53
|
+
if not isinstance(decoded_schema, dict):
|
|
54
|
+
raise ValueError("authoring evidence signature schema must be a JSON object")
|
|
55
|
+
jsonschema.Draft202012Validator.check_schema(decoded_schema)
|
|
56
|
+
cached_schema = cast(dict[str, Any], decoded_schema)
|
|
57
|
+
_signature_schema_cache = cached_schema
|
|
58
|
+
return cached_schema
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def authoring_evidence_signature_schema() -> dict[str, Any]:
|
|
62
|
+
"""Return an owned copy of the detached-signature schema."""
|
|
63
|
+
|
|
64
|
+
return copy.deepcopy(_signature_schema())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _signature_message(evidence_digest: str) -> bytes:
|
|
68
|
+
if not _DIGEST_PATTERN.fullmatch(evidence_digest):
|
|
69
|
+
raise ValueError("signed evidence digest must be lowercase SHA-256")
|
|
70
|
+
return _SIGNATURE_MESSAGE_PREFIX + evidence_digest.encode("ascii")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class AuthoringEvidenceSignature:
|
|
75
|
+
"""Canonical detached signature for one verified evidence digest."""
|
|
76
|
+
|
|
77
|
+
evidence_digest: str
|
|
78
|
+
key_id: str
|
|
79
|
+
signature_base64: str
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if not _DIGEST_PATTERN.fullmatch(self.evidence_digest):
|
|
83
|
+
raise ValueError("signature evidence digest must be lowercase SHA-256")
|
|
84
|
+
if not _DIGEST_PATTERN.fullmatch(self.key_id):
|
|
85
|
+
raise ValueError("signature key identifier must be lowercase SHA-256")
|
|
86
|
+
canonical_ed25519_signature_bytes(self.signature_base64)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def signature_bytes(self) -> bytes:
|
|
90
|
+
return canonical_ed25519_signature_bytes(self.signature_base64)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[str, object]:
|
|
93
|
+
return {
|
|
94
|
+
"format": AUTHORING_EVIDENCE_SIGNATURE_FORMAT,
|
|
95
|
+
"algorithm": AUTHORING_EVIDENCE_SIGNATURE_ALGORITHM,
|
|
96
|
+
"evidence_digest": self.evidence_digest,
|
|
97
|
+
"key_id": self.key_id,
|
|
98
|
+
"signature": self.signature_base64,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def to_json(self) -> str:
|
|
102
|
+
return _canonical_json(self.to_dict())
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_json(cls, serialized_signature: str) -> AuthoringEvidenceSignature:
|
|
106
|
+
"""Parse one strict canonical detached signature."""
|
|
107
|
+
|
|
108
|
+
decoded_signature = strict_json_loads(serialized_signature)
|
|
109
|
+
jsonschema.Draft202012Validator(_signature_schema()).validate(decoded_signature)
|
|
110
|
+
if not isinstance(decoded_signature, dict):
|
|
111
|
+
raise AssertionError("the signature schema accepted a non-object document")
|
|
112
|
+
if serialized_signature.strip() != _canonical_json(decoded_signature):
|
|
113
|
+
raise ValueError("authoring evidence signature must use canonical compact JSON")
|
|
114
|
+
return cls(
|
|
115
|
+
evidence_digest=cast(str, decoded_signature["evidence_digest"]),
|
|
116
|
+
key_id=cast(str, decoded_signature["key_id"]),
|
|
117
|
+
signature_base64=cast(str, decoded_signature["signature"]),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class AuthoringEvidenceAuthentication:
|
|
123
|
+
"""A verified evidence report authenticated by one trusted public key."""
|
|
124
|
+
|
|
125
|
+
evidence: AuthoringEvidenceVerification
|
|
126
|
+
key_id: str
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, object]:
|
|
129
|
+
return {
|
|
130
|
+
"authenticated": True,
|
|
131
|
+
"algorithm": AUTHORING_EVIDENCE_SIGNATURE_ALGORITHM,
|
|
132
|
+
"key_id": self.key_id,
|
|
133
|
+
"evidence": self.evidence.to_dict(),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sign_authoring_evidence(
|
|
138
|
+
serialized_evidence: str,
|
|
139
|
+
private_key_pem: bytes,
|
|
140
|
+
*,
|
|
141
|
+
expected_repository_revision: str | None = None,
|
|
142
|
+
) -> AuthoringEvidenceSignature:
|
|
143
|
+
"""Verify evidence and create its deterministic detached signature."""
|
|
144
|
+
|
|
145
|
+
evidence_verification = verify_authoring_evidence(
|
|
146
|
+
serialized_evidence,
|
|
147
|
+
expected_repository_revision=expected_repository_revision,
|
|
148
|
+
)
|
|
149
|
+
detached_signature = sign_detached_ed25519(
|
|
150
|
+
_signature_message(evidence_verification.digest),
|
|
151
|
+
private_key_pem,
|
|
152
|
+
)
|
|
153
|
+
return AuthoringEvidenceSignature(
|
|
154
|
+
evidence_digest=evidence_verification.digest,
|
|
155
|
+
key_id=detached_signature.key_id,
|
|
156
|
+
signature_base64=detached_signature.signature_base64,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def verify_authoring_evidence_signature(
|
|
161
|
+
serialized_evidence: str,
|
|
162
|
+
serialized_signature: str,
|
|
163
|
+
public_key_pem: bytes,
|
|
164
|
+
*,
|
|
165
|
+
expected_repository_revision: str | None = None,
|
|
166
|
+
) -> AuthoringEvidenceAuthentication:
|
|
167
|
+
"""Verify evidence and authenticate its digest with a trusted public key."""
|
|
168
|
+
|
|
169
|
+
evidence_verification = verify_authoring_evidence(
|
|
170
|
+
serialized_evidence,
|
|
171
|
+
expected_repository_revision=expected_repository_revision,
|
|
172
|
+
)
|
|
173
|
+
evidence_signature = AuthoringEvidenceSignature.from_json(serialized_signature)
|
|
174
|
+
if evidence_signature.evidence_digest != evidence_verification.digest:
|
|
175
|
+
raise ValueError("authoring evidence signature names a different evidence digest")
|
|
176
|
+
try:
|
|
177
|
+
public_key_id = verify_detached_ed25519(
|
|
178
|
+
_signature_message(evidence_verification.digest),
|
|
179
|
+
evidence_signature.signature_base64,
|
|
180
|
+
public_key_pem,
|
|
181
|
+
expected_key_id=evidence_signature.key_id,
|
|
182
|
+
)
|
|
183
|
+
except DetachedEd25519KeyMismatchError:
|
|
184
|
+
raise ValueError("authoring evidence signature names a different public key") from None
|
|
185
|
+
except DetachedEd25519VerificationError as signature_error:
|
|
186
|
+
raise ValueError("authoring evidence signature is invalid") from signature_error
|
|
187
|
+
return AuthoringEvidenceAuthentication(evidence=evidence_verification, key_id=public_key_id)
|