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.
- sovereign_core-1.0.0/PKG-INFO +18 -0
- sovereign_core-1.0.0/README.md +0 -0
- sovereign_core-1.0.0/pyproject.toml +36 -0
- sovereign_core-1.0.0/setup.cfg +4 -0
- sovereign_core-1.0.0/src/sovereign_core/__init__.py +11 -0
- sovereign_core-1.0.0/src/sovereign_core/cli.py +109 -0
- sovereign_core-1.0.0/src/sovereign_core/crypto.py +475 -0
- sovereign_core-1.0.0/src/sovereign_core/gateway.py +504 -0
- sovereign_core-1.0.0/src/sovereign_core/py.typed +0 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/PKG-INFO +18 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/SOURCES.txt +15 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/dependency_links.txt +1 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/entry_points.txt +2 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/requires.txt +2 -0
- sovereign_core-1.0.0/src/sovereign_core.egg-info/top_level.txt +1 -0
- sovereign_core-1.0.0/tests/test_crypto.py +378 -0
- sovereign_core-1.0.0/tests/test_gateway.py +835 -0
|
@@ -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,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
|