sovereign-core 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovereign-core
3
+ Version: 1.0.0
4
+ Summary: Core schemas and protocols for Sovereign Systems
5
+ Author-email: kenwalger <kenalger@comcast.net>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kenwalger/sovereign-sdk
8
+ Project-URL: Repository, https://github.com/kenwalger/sovereign-sdk
9
+ Project-URL: Changelog, https://github.com/kenwalger/sovereign-sdk/blob/main/CHANGELOG.md
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Security
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: cryptography>=48.0.0
18
+ Requires-Dist: pydantic>=2.7.0
File without changes
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "sovereign-core"
3
+ version = "1.0.0"
4
+ description = "Core schemas and protocols for Sovereign Systems"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "kenwalger", email = "kenalger@comcast.net" }
10
+ ]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Intended Audience :: Developers",
15
+ "Topic :: Security",
16
+ "Topic :: Software Development :: Libraries",
17
+ ]
18
+ dependencies = [
19
+ "cryptography>=48.0.0",
20
+ "pydantic>=2.7.0",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/kenwalger/sovereign-sdk"
25
+ Repository = "https://github.com/kenwalger/sovereign-sdk"
26
+ Changelog = "https://github.com/kenwalger/sovereign-sdk/blob/main/CHANGELOG.md"
27
+
28
+ [project.scripts]
29
+ sovereign-verify = "sovereign_core.cli:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=77.0.0"]
36
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """
2
+ Sovereign Core
3
+ Data provenance, cryptographic identity, and ingestion boundaries.
4
+ """
5
+
6
+ __version__ = "1.0.0"
7
+
8
+ from .crypto import SovereignKeyManager, ForensicReceipt
9
+ from .gateway import SessionContext
10
+
11
+ __all__ = ["SovereignKeyManager", "ForensicReceipt", "SessionContext"]
@@ -0,0 +1,109 @@
1
+ """sovereign-verify — stateless ForensicReceipt verification CLI.
2
+
3
+ Accepts a receipt JSON file and a base64-encoded Ed25519 public key.
4
+ Exits 0 on verified, 1 on tampered or invalid.
5
+
6
+ Usage:
7
+ sovereign-verify --receipt receipt.json --public-key <base64-key>
8
+ """
9
+ import argparse
10
+ import base64
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from cryptography.hazmat.primitives.asymmetric import ed25519
16
+
17
+
18
+ def _verify(receipt: dict, expected_public_key: str) -> bool:
19
+ """Return True if the receipt passes key-pin and Ed25519 signature checks.
20
+
21
+ Performs two sequential verification steps matching the logic of
22
+ SovereignKeyManager.verify_receipt:
23
+
24
+ 1. Key-pin assertion — receipt["public_key"] must equal expected_public_key.
25
+ 2. Signature verification — the Ed25519 signature must be valid over the
26
+ canonical manifest {"metadata": …, "payload_hash": …, "timestamp": …}.
27
+
28
+ Note: payload-hash revalidation (step 2 of verify_receipt) requires the
29
+ original payload and is not performed here; the CLI operates on the receipt
30
+ alone for stateless out-of-band auditing.
31
+
32
+ Args:
33
+ receipt: Parsed ForensicReceipt dictionary.
34
+ expected_public_key: Base64-encoded raw Ed25519 public key string.
35
+
36
+ Returns:
37
+ True if the receipt is structurally intact and key-pinned to the
38
+ provided public key, False otherwise.
39
+ """
40
+ try:
41
+ if receipt.get("public_key") != expected_public_key:
42
+ return False
43
+
44
+ pub_bytes = base64.b64decode(receipt["public_key"])
45
+ sig_bytes = base64.b64decode(receipt["signature"])
46
+ pub_key = ed25519.Ed25519PublicKey.from_public_bytes(pub_bytes)
47
+
48
+ manifest = {
49
+ "metadata": receipt["metadata"],
50
+ "payload_hash": receipt["payload_hash"],
51
+ "timestamp": receipt["timestamp"],
52
+ }
53
+ canonical = json.dumps(manifest, sort_keys=True, default=str)
54
+ pub_key.verify(sig_bytes, canonical.encode("utf-8"))
55
+ return True
56
+ except Exception:
57
+ # Fail-closed: any structural, encoding, or cryptographic error means unverified.
58
+ return False
59
+
60
+
61
+ def main() -> None:
62
+ """CLI entry point for sovereign-verify."""
63
+ parser = argparse.ArgumentParser(
64
+ prog="sovereign-verify",
65
+ description=(
66
+ "Verify a Sovereign Systems ForensicReceipt against an Ed25519 public key. "
67
+ "Exits 0 on verified, 1 on tampered or invalid input."
68
+ ),
69
+ )
70
+ parser.add_argument(
71
+ "--receipt",
72
+ required=True,
73
+ metavar="FILE",
74
+ help="Path to a JSON file containing the ForensicReceipt to verify.",
75
+ )
76
+ parser.add_argument(
77
+ "--public-key",
78
+ required=True,
79
+ metavar="BASE64_KEY",
80
+ help="Base64-encoded raw Ed25519 public key string to pin the receipt against.",
81
+ )
82
+ args = parser.parse_args()
83
+
84
+ receipt_path = Path(args.receipt)
85
+ if not receipt_path.is_file():
86
+ print(f"Error: receipt file not found: {receipt_path}", file=sys.stderr)
87
+ sys.exit(1)
88
+
89
+ try:
90
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
91
+ except json.JSONDecodeError as exc:
92
+ print(f"Error: receipt file is not valid JSON: {exc}", file=sys.stderr)
93
+ sys.exit(1)
94
+
95
+ if not isinstance(receipt, dict):
96
+ sys.stderr.write("🚨 Error: Invalid manifest format. The receipt file must contain a valid JSON object.\n")
97
+ sys.exit(1)
98
+
99
+ if _verify(receipt, args.public_key):
100
+ print(f"Verified ✓ payload_hash: {receipt.get('payload_hash', 'unknown')}")
101
+ sys.exit(0)
102
+ else:
103
+ print(
104
+ "Tampered ✗ Receipt failed cryptographic verification.",
105
+ file=sys.stderr,
106
+ )
107
+ print(f" payload_hash : {receipt.get('payload_hash', 'unknown')}", file=sys.stderr)
108
+ print(f" timestamp : {receipt.get('timestamp', 'unknown')}", file=sys.stderr)
109
+ sys.exit(1)
@@ -0,0 +1,475 @@
1
+ import base64
2
+ import json
3
+ import hashlib
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, TypedDict
10
+ from pydantic import BaseModel, Field
11
+
12
+ from cryptography.hazmat.primitives.asymmetric import ed25519
13
+ from cryptography.hazmat.primitives import serialization
14
+
15
+
16
+ class ForensicReceipt(TypedDict):
17
+ """Immutable, cryptographically sealed provenance record for a single tool execution.
18
+
19
+ Every field in this envelope is covered by the Ed25519 signature stored in
20
+ ``signature``. Mutating any value — including those inside ``metadata`` —
21
+ causes :meth:`SovereignKeyManager.verify_receipt` to return ``False``.
22
+
23
+ Attributes:
24
+ timestamp: ISO 8601 UTC timestamp captured at receipt-minting time.
25
+ payload_hash: Hex-encoded SHA-256 digest of the deterministically
26
+ serialised execution payload. During verification this value is
27
+ explicitly cross-checked against a fresh digest re-derived from the
28
+ original payload; a mismatch causes
29
+ :meth:`SovereignKeyManager.verify_receipt` to return ``False``
30
+ before the signature check is even attempted.
31
+ public_key: Base64-encoded raw Ed25519 public key bytes used to
32
+ produce and verify ``signature``.
33
+ signature: Base64-encoded raw Ed25519 signature over the canonical
34
+ manifest (``timestamp`` + ``payload_hash`` + ``metadata``).
35
+ metadata: Arbitrary key/value execution annotations sealed inside the
36
+ signature, e.g. ``execution_success``, ``runtime``, ``py_ver``.
37
+ """
38
+
39
+ timestamp: str
40
+ payload_hash: str
41
+ public_key: str
42
+ signature: str
43
+ metadata: dict[str, Any]
44
+
45
+
46
+ class ReceiptSchema(BaseModel):
47
+ """Pydantic validation layer applied to every freshly minted ForensicReceipt.
48
+
49
+ Attributes:
50
+ timestamp: UTC datetime of receipt creation. Defaults to the current
51
+ instant when not supplied explicitly.
52
+ payload_hash: Hex-encoded SHA-256 digest of the signed payload.
53
+ public_key: Base64-encoded raw Ed25519 public key bytes.
54
+ signature: Base64-encoded raw Ed25519 signature bytes.
55
+ metadata: Arbitrary execution annotation dictionary.
56
+ """
57
+
58
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
59
+ payload_hash: str
60
+ public_key: str
61
+ signature: str
62
+ metadata: dict[str, Any] = Field(default_factory=dict)
63
+
64
+
65
+ class SovereignKeyManager:
66
+ """Manages the local-first Ed25519 identity lifecycle and cryptographic receipt operations.
67
+
68
+ All private key material is stored on disk encrypted with the passphrase
69
+ sourced from the ``SOVEREIGN_NODE_SECRET`` environment variable. The
70
+ corresponding public key is written as plain PEM for audit and rotation
71
+ purposes.
72
+
73
+ Both the legacy-migration and greenfield key-write paths enforce identical,
74
+ umask-independent ``0o600`` file permissions via descriptor-level
75
+ ``os.fchmod`` (with a path-based ``os.chmod`` fallback on platforms that
76
+ lack ``fchmod``) applied to the open staging file descriptor before any
77
+ bytes are written, and both promote the fully synced temp file over the
78
+ target path via ``os.replace()`` for atomicity.
79
+
80
+ Args:
81
+ key_dir: Directory path for on-disk keypair persistence. Defaults to
82
+ ``.keys`` relative to the current working directory.
83
+ """
84
+
85
+ def __init__(self, key_dir: str | Path = ".keys") -> None:
86
+ """Initialises path references for the identity keypair. No I/O is performed.
87
+
88
+ Args:
89
+ key_dir: Directory where ``sovereign_identity.pem`` and
90
+ ``sovereign_identity.pub`` will be written or read.
91
+ """
92
+ self.key_dir = Path(key_dir)
93
+ self.private_key_path = self.key_dir / "sovereign_identity.pem"
94
+ self.public_key_path = self.key_dir / "sovereign_identity.pub"
95
+
96
+ self._private_key: ed25519.Ed25519PrivateKey | None = None
97
+ self._public_key: ed25519.Ed25519PublicKey | None = None
98
+
99
+ def _resolve_node_secret(self) -> bytes:
100
+ """Reads and validates the PEM encryption passphrase from the environment.
101
+
102
+ Returns:
103
+ UTF-8 encoded bytes of the ``SOVEREIGN_NODE_SECRET`` value.
104
+
105
+ Raises:
106
+ RuntimeError: If ``SOVEREIGN_NODE_SECRET`` is absent or blank.
107
+ """
108
+ secret = os.getenv("SOVEREIGN_NODE_SECRET", "").strip()
109
+ if not secret:
110
+ raise RuntimeError(
111
+ "SOVEREIGN_NODE_SECRET is not set. "
112
+ "An explicit cryptographic passcode wrapper must be declared before "
113
+ "initializing the sovereign identity keypair."
114
+ )
115
+ return secret.encode("utf-8")
116
+
117
+ @property
118
+ def has_identity(self) -> bool:
119
+ """Returns ``True`` when the Ed25519 keypair is fully initialised in memory.
120
+
121
+ A clean, public alternative to inspecting the private ``_private_key`` or
122
+ ``_public_key`` slots from outside the class. Callers should check this
123
+ property before calling :meth:`public_key` or :meth:`get_base64_public_key`
124
+ to avoid the ``RuntimeError`` that those methods raise when the keypair is
125
+ not yet loaded.
126
+
127
+ Returns:
128
+ ``True`` if both ``_private_key`` and ``_public_key`` are non-``None``,
129
+ ``False`` otherwise.
130
+ """
131
+ return self._private_key is not None and self._public_key is not None
132
+
133
+ def load_or_generate_keypair(self) -> tuple[str, str]:
134
+ """Loads an existing Ed25519 keypair or generates and persists a new one.
135
+
136
+ Loading follows a two-attempt upgrade path to handle legacy deployments:
137
+
138
+ 1. The PEM file is first loaded with the active ``SOVEREIGN_NODE_SECRET``
139
+ passphrase via
140
+ :func:`~cryptography.hazmat.primitives.serialization.BestAvailableEncryption`.
141
+ 2. If that raises :exc:`TypeError` or :exc:`ValueError` (indicating an
142
+ unencrypted legacy key), a second attempt is made with
143
+ ``password=None``. On success, an advisory warning is printed to
144
+ ``stderr`` and the key is immediately re-written to disk using the
145
+ current passphrase, migrating the file to the encrypted format
146
+ transparently.
147
+ 3. If both attempts fail, a :exc:`RuntimeError` is raised with explicit
148
+ rotation guidance for the operator.
149
+
150
+ Both the migration and greenfield paths enforce ``0o600`` permissions
151
+ via a descriptor-level call — ``os.fchmod(fd, 0o600)`` on POSIX hosts,
152
+ with a portable fallback to ``os.chmod(path, 0o600)`` on Windows — applied
153
+ immediately after the staging temp file is created and before any bytes
154
+ are written. Operating on the file descriptor rather than the path closes
155
+ the TOCTOU window present in path-based permission calls. The fully synced
156
+ temp file is promoted over the target path via ``os.replace()`` for
157
+ atomicity on both paths.
158
+
159
+ Returns:
160
+ A 2-tuple of ``(base64_private_key, base64_public_key)`` where each
161
+ element is the raw-bytes representation of the respective key
162
+ encoded as a base64 string.
163
+
164
+ Raises:
165
+ RuntimeError: If ``SOVEREIGN_NODE_SECRET`` is not set, or if the
166
+ on-disk PEM cannot be loaded by either attempt (corrupted file
167
+ or wrong passphrase).
168
+ """
169
+ passphrase = self._resolve_node_secret()
170
+
171
+ if self.private_key_path.exists():
172
+ pem_data: bytes = self.private_key_path.read_bytes()
173
+
174
+ try:
175
+ self._private_key = serialization.load_pem_private_key(pem_data, password=passphrase)
176
+ except (TypeError, ValueError):
177
+ # First attempt failed — check for a legacy unencrypted key.
178
+ try:
179
+ self._private_key = serialization.load_pem_private_key(pem_data, password=None)
180
+ except Exception:
181
+ raise RuntimeError(
182
+ f"Cannot load private key at '{self.private_key_path}': the file is "
183
+ "either corrupted or was encrypted with a different "
184
+ "SOVEREIGN_NODE_SECRET value. Rotate the key by removing the file "
185
+ "and restarting the node to generate a new encrypted identity."
186
+ )
187
+
188
+ # Legacy unencrypted key loaded — warn and atomically re-encrypt in place.
189
+ print(
190
+ "⚠️ Legacy unencrypted keypair detected. Automatically upgrading "
191
+ "identity configuration to encrypted storage format...",
192
+ file=sys.stderr,
193
+ )
194
+ encrypted_pem: bytes = self._private_key.private_bytes(
195
+ encoding=serialization.Encoding.PEM,
196
+ format=serialization.PrivateFormat.PKCS8,
197
+ encryption_algorithm=serialization.BestAvailableEncryption(passphrase),
198
+ )
199
+ tmp_path: str = ""
200
+ try:
201
+ with tempfile.NamedTemporaryFile(
202
+ dir=os.path.dirname(self.private_key_path),
203
+ delete=False,
204
+ ) as tmp:
205
+ tmp_path = tmp.name
206
+ if hasattr(os, "fchmod"):
207
+ os.fchmod(tmp.fileno(), 0o600)
208
+ else:
209
+ os.chmod(tmp_path, 0o600)
210
+ tmp.write(encrypted_pem)
211
+ tmp.flush()
212
+ os.fsync(tmp.fileno())
213
+ os.replace(tmp_path, self.private_key_path)
214
+ tmp_path = "" # Renamed successfully; nothing left to clean up.
215
+ finally:
216
+ if tmp_path:
217
+ try:
218
+ os.remove(tmp_path)
219
+ except FileNotFoundError:
220
+ pass
221
+
222
+ self._public_key = self._private_key.public_key()
223
+ else:
224
+ self._private_key = ed25519.Ed25519PrivateKey.generate()
225
+ self._public_key = self._private_key.public_key()
226
+
227
+ self.key_dir.mkdir(parents=True, exist_ok=True)
228
+ pem_bytes = self._private_key.private_bytes(
229
+ encoding=serialization.Encoding.PEM,
230
+ format=serialization.PrivateFormat.PKCS8,
231
+ encryption_algorithm=serialization.BestAvailableEncryption(passphrase),
232
+ )
233
+ tmp_path: str = ""
234
+ try:
235
+ with tempfile.NamedTemporaryFile(
236
+ dir=os.path.dirname(self.private_key_path),
237
+ delete=False,
238
+ ) as tmp_file:
239
+ tmp_path = tmp_file.name
240
+ if hasattr(os, "fchmod"):
241
+ os.fchmod(tmp_file.fileno(), 0o600)
242
+ else:
243
+ os.chmod(tmp_path, 0o600)
244
+ tmp_file.write(pem_bytes)
245
+ tmp_file.flush()
246
+ os.fsync(tmp_file.fileno())
247
+ os.replace(tmp_path, self.private_key_path)
248
+ tmp_path = ""
249
+ except Exception:
250
+ if tmp_path:
251
+ try:
252
+ os.remove(tmp_path)
253
+ except FileNotFoundError:
254
+ pass
255
+ raise
256
+
257
+ with open(self.public_key_path, "wb") as f:
258
+ f.write(
259
+ self._public_key.public_bytes(
260
+ encoding=serialization.Encoding.PEM,
261
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
262
+ )
263
+ )
264
+
265
+ return self.get_base64_private_key(), self.get_base64_public_key()
266
+
267
+ def get_base64_private_key(self) -> str:
268
+ """Exports the in-memory private key as a base64-encoded raw byte string.
269
+
270
+ Returns:
271
+ Base64-encoded string of the 32-byte Ed25519 private key seed.
272
+ This export is unencrypted and lives only in memory; it is never
273
+ written to disk by this method.
274
+
275
+ Raises:
276
+ RuntimeError: If the keypair has not been loaded via
277
+ :meth:`load_or_generate_keypair`.
278
+ """
279
+ if not self._private_key:
280
+ raise RuntimeError("Keypair not loaded.")
281
+ raw_bytes = self._private_key.private_bytes(
282
+ encoding=serialization.Encoding.Raw,
283
+ format=serialization.PrivateFormat.Raw,
284
+ encryption_algorithm=serialization.NoEncryption()
285
+ )
286
+ return base64.b64encode(raw_bytes).decode("utf-8")
287
+
288
+ def get_base64_public_key(self) -> str:
289
+ """Exports the in-memory public key as a base64-encoded raw byte string.
290
+
291
+ Returns:
292
+ Base64-encoded string of the 32-byte Ed25519 public key.
293
+
294
+ Raises:
295
+ RuntimeError: If the keypair has not been loaded via
296
+ :meth:`load_or_generate_keypair`.
297
+ """
298
+ if not self._public_key:
299
+ raise RuntimeError("Keypair not loaded.")
300
+ raw_bytes = self._public_key.public_bytes(
301
+ encoding=serialization.Encoding.Raw,
302
+ format=serialization.PublicFormat.Raw
303
+ )
304
+ return base64.b64encode(raw_bytes).decode("utf-8")
305
+
306
+ @property
307
+ def public_key(self) -> str:
308
+ """The node's pinned public key as a base64-encoded string.
309
+
310
+ Convenience accessor that returns the same value as
311
+ :meth:`get_base64_public_key`. Intended to be passed directly as the
312
+ ``expected_public_key`` argument to
313
+ :meth:`verify_receipt` so that call sites can enforce key pinning
314
+ without holding a separate reference to the raw key string.
315
+
316
+ Returns:
317
+ Base64-encoded string of the 32-byte Ed25519 public key.
318
+
319
+ Raises:
320
+ RuntimeError: If the keypair has not been loaded via
321
+ :meth:`load_or_generate_keypair`.
322
+ """
323
+ return self.get_base64_public_key()
324
+
325
+ def generate_receipt(
326
+ self,
327
+ payload: dict[str, Any],
328
+ metadata: dict[str, Any] | None = None,
329
+ ) -> ForensicReceipt:
330
+ """Mints a cryptographically sealed ForensicReceipt for the given payload.
331
+
332
+ Assembles a canonical manifest that binds ``timestamp``, ``payload_hash``,
333
+ and ``metadata`` into a single deterministic JSON string, then signs that
334
+ string with the node's Ed25519 private key. Because the entire manifest
335
+ is signed, any post-issuance mutation of ``metadata`` — including flipping
336
+ ``execution_success`` — is detectable by :meth:`verify_receipt`.
337
+
338
+ Args:
339
+ payload: Arbitrary dictionary representing the tool's execution result.
340
+ Serialised with ``json.dumps(sort_keys=True, default=str)`` before
341
+ hashing to guarantee deterministic output.
342
+ metadata: Optional key/value annotations embedded in the sealed
343
+ envelope, e.g. ``{"execution_success": True, "runtime": "…"}``.
344
+ Defaults to an empty dictionary when ``None``.
345
+
346
+ Returns:
347
+ A :class:`ForensicReceipt` TypedDict whose ``signature`` covers the
348
+ ``timestamp``, ``payload_hash``, and ``metadata`` fields atomically.
349
+
350
+ Raises:
351
+ RuntimeError: If the keypair is not loaded and
352
+ ``SOVEREIGN_NODE_SECRET`` is unset.
353
+ """
354
+ if not self._private_key:
355
+ self.load_or_generate_keypair()
356
+
357
+ metadata = metadata or {}
358
+
359
+ # 1. Stable SHA-256 digest of the payload
360
+ serialized_payload = json.dumps(payload, sort_keys=True, default=str)
361
+ payload_hash = hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest()
362
+
363
+ # 2. Canonical manifest that binds every security-critical field
364
+ timestamp = datetime.now(timezone.utc).isoformat()
365
+ manifest = {
366
+ "metadata": metadata,
367
+ "payload_hash": payload_hash,
368
+ "timestamp": timestamp,
369
+ }
370
+ canonical = json.dumps(manifest, sort_keys=True, default=str)
371
+
372
+ # 3. Sign the full manifest, not just the raw payload
373
+ raw_signature = self._private_key.sign(canonical.encode("utf-8"))
374
+ signature_b64 = base64.b64encode(raw_signature).decode("utf-8")
375
+
376
+ # 4. Validate structure through Pydantic before returning
377
+ validated = ReceiptSchema(
378
+ timestamp=timestamp,
379
+ payload_hash=payload_hash,
380
+ public_key=self.get_base64_public_key(),
381
+ signature=signature_b64,
382
+ metadata=metadata,
383
+ )
384
+
385
+ # Return the exact timestamp string that was signed so verify_receipt can reconstruct it
386
+ return ForensicReceipt(
387
+ timestamp=timestamp,
388
+ payload_hash=validated.payload_hash,
389
+ public_key=validated.public_key,
390
+ signature=validated.signature,
391
+ metadata=validated.metadata,
392
+ )
393
+
394
+ @staticmethod
395
+ def verify_receipt(
396
+ receipt: ForensicReceipt,
397
+ original_payload: dict[str, Any],
398
+ expected_public_key: str | None = None,
399
+ ) -> bool:
400
+ """Verifies the cryptographic integrity of a ForensicReceipt against the original payload.
401
+
402
+ Verification proceeds in three sequential, independent steps:
403
+
404
+ 1. **Key-pin assertion** *(optional)*: When ``expected_public_key`` is
405
+ supplied, the base64-encoded public key string extracted from
406
+ ``receipt["public_key"]`` is compared directly against it. A
407
+ mismatch returns ``False`` immediately, before any cryptographic
408
+ operation is attempted. This prevents *identity self-attestation
409
+ forgery*: without this gate, an attacker could mint a receipt with
410
+ a rogue keypair that verifies correctly against its own public key
411
+ rather than the node's pinned identity. Pass
412
+ :attr:`SovereignKeyManager.public_key` to enforce provenance.
413
+
414
+ 2. **Explicit payload-hash assertion**: The SHA-256 digest of
415
+ ``original_payload`` is re-derived and compared byte-for-byte
416
+ against ``receipt["payload_hash"]``. A mismatch returns ``False``
417
+ before any signature operation is attempted, closing the *phantom
418
+ field* attack vector.
419
+
420
+ 3. **Signature verification**: The canonical manifest
421
+ ``{"metadata": …, "payload_hash": …, "timestamp": …}`` is
422
+ reconstructed using ``receipt["payload_hash"]`` (confirmed
423
+ consistent with ``original_payload``) and verified against the
424
+ Ed25519 ``signature`` embedded in the envelope. Any mutation of
425
+ ``metadata`` or ``timestamp`` is caught here.
426
+
427
+ Args:
428
+ receipt: The :class:`ForensicReceipt` envelope to audit.
429
+ original_payload: The exact payload dictionary passed to
430
+ :meth:`generate_receipt`. Its SHA-256 digest is re-derived
431
+ internally and compared against ``receipt["payload_hash"]``;
432
+ the caller must not pre-hash it.
433
+ expected_public_key: Optional base64-encoded Ed25519 public key
434
+ string that the receipt's embedded key must match exactly.
435
+ When provided, any receipt whose ``public_key`` field differs
436
+ from this value is rejected before crypto operations begin.
437
+ Pass :attr:`SovereignKeyManager.public_key` to pin receipts
438
+ to the local node identity. Defaults to ``None`` (no pin).
439
+
440
+ Returns:
441
+ ``True`` if and only if all active checks pass: the optional key
442
+ pin matches, the re-derived payload hash equals
443
+ ``receipt["payload_hash"]``, and the Ed25519 signature is valid
444
+ for the reconstructed manifest. ``False`` for any pin mismatch,
445
+ hash mismatch, signature failure, or decoding error.
446
+ """
447
+ try:
448
+ # Step 1 — key-pin assertion (identity provenance guard).
449
+ # Fail fast on a string comparison before touching any crypto.
450
+ if expected_public_key is not None and receipt["public_key"] != expected_public_key:
451
+ return False
452
+
453
+ public_bytes = base64.b64decode(receipt["public_key"])
454
+ signature_bytes = base64.b64decode(receipt["signature"])
455
+ public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_bytes)
456
+
457
+ # Step 2 — explicit payload-hash assertion (phantom field guard).
458
+ serialized_payload = json.dumps(original_payload, sort_keys=True, default=str)
459
+ expected_payload_hash = hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest()
460
+ if expected_payload_hash != receipt["payload_hash"]:
461
+ return False
462
+
463
+ # Step 3 — reconstruct the canonical manifest and verify the signature.
464
+ # receipt["payload_hash"] is now confirmed to match original_payload.
465
+ manifest = {
466
+ "metadata": receipt["metadata"],
467
+ "payload_hash": receipt["payload_hash"],
468
+ "timestamp": receipt["timestamp"],
469
+ }
470
+ canonical = json.dumps(manifest, sort_keys=True, default=str)
471
+
472
+ public_key.verify(signature_bytes, canonical.encode("utf-8"))
473
+ return True
474
+ except Exception:
475
+ return False