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.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. 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)