dcl-core 0.3.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.
- dcl_core-0.3.0/LICENSE +17 -0
- dcl_core-0.3.0/PKG-INFO +113 -0
- dcl_core-0.3.0/README.md +90 -0
- dcl_core-0.3.0/dcl_core/__init__.py +22 -0
- dcl_core-0.3.0/dcl_core/chain.py +242 -0
- dcl_core-0.3.0/dcl_core/consensus.py +82 -0
- dcl_core-0.3.0/dcl_core/seal.py +65 -0
- dcl_core-0.3.0/dcl_core/verify.py +90 -0
- dcl_core-0.3.0/dcl_core.egg-info/PKG-INFO +113 -0
- dcl_core-0.3.0/dcl_core.egg-info/SOURCES.txt +15 -0
- dcl_core-0.3.0/dcl_core.egg-info/dependency_links.txt +1 -0
- dcl_core-0.3.0/dcl_core.egg-info/requires.txt +3 -0
- dcl_core-0.3.0/dcl_core.egg-info/top_level.txt +1 -0
- dcl_core-0.3.0/pyproject.toml +33 -0
- dcl_core-0.3.0/setup.cfg +4 -0
- dcl_core-0.3.0/tests/test_fixes.py +125 -0
- dcl_core-0.3.0/tests/test_verify_matches_chain_state.py +98 -0
dcl_core-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
|
|
17
|
+
Copyright 2026 Fronesis Labs
|
dcl_core-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dcl-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Tamper-evident record chain, offline chain verification, and multi-party consensus primitives for AI agent audit systems (DCL / Leibniz Layer protocol).
|
|
5
|
+
Author: Fronesis Labs
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://mcp.fronesislabs.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Fronesis-Labs/dcl-core
|
|
9
|
+
Project-URL: Protocol, https://github.com/Fronesis-Labs/dcl-sdk/blob/main/PROTOCOL.md
|
|
10
|
+
Keywords: mcp,model-context-protocol,audit,hash-chain,ai-agents,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# DCL Core
|
|
25
|
+
|
|
26
|
+
Tamper-evident record chain + multi-party consensus primitives for AI agent
|
|
27
|
+
audit systems. Part of the Deterministic Commitment Layer / Leibniz Layer™
|
|
28
|
+
ecosystem by Fronesis Labs.
|
|
29
|
+
|
|
30
|
+
This is a clean-history consolidation: the single-agent chain logic
|
|
31
|
+
previously duplicated inside `dcl-webhook`, and the multi-agent consensus
|
|
32
|
+
logic from `dcl-v2`, unified into one module with two known issues fixed.
|
|
33
|
+
|
|
34
|
+
## What changed vs the previous implementations
|
|
35
|
+
|
|
36
|
+
### 1. `ChainState.verify()` now detects content tampering
|
|
37
|
+
|
|
38
|
+
The chain logic previously embedded in `dcl-webhook/dcl_core.py` only
|
|
39
|
+
checked that `prev_hash` pointers linked correctly between rows. It never
|
|
40
|
+
recomputed a row's hash from its own stored fields. That meant a party with
|
|
41
|
+
direct database access could edit `verdict`, `confidence`, or `reason` on
|
|
42
|
+
an existing row — without touching `tx_hash`/`prev_hash` — and `verify()`
|
|
43
|
+
would still report the chain as clean. This defeated the core tamper-evident
|
|
44
|
+
claim.
|
|
45
|
+
|
|
46
|
+
`verify()` here recomputes each row's hash from all of its stored fields
|
|
47
|
+
and compares it against the stored `tx_hash`. Editing any field now breaks
|
|
48
|
+
verification. See `tests/test_fixes.py::test_detects_content_tamper_not_just_link_break`.
|
|
49
|
+
|
|
50
|
+
### 2. Super-Hash consensus no longer uses XOR
|
|
51
|
+
|
|
52
|
+
`dcl-v2`'s original formula was `H*_t = hash(h_t1 ⊕ h_t2 ⊕ ... ⊕ h_tM)`.
|
|
53
|
+
XOR is commutative and reversible: a party submitting last (or able to
|
|
54
|
+
observe the running combination) can solve for a contribution that forces
|
|
55
|
+
the final Super-Hash to any target value, without breaking any hash
|
|
56
|
+
function — cheap arithmetic forgery, not a cryptographic break.
|
|
57
|
+
|
|
58
|
+
`compute_super_hash()` instead sorts contributions by party ID and hashes
|
|
59
|
+
their length-prefixed concatenation. Order no longer matters (no
|
|
60
|
+
last-mover advantage) and the XOR-forgery trick no longer works. See
|
|
61
|
+
`tests/test_fixes.py::test_super_hash_xor_forgery_no_longer_trivial`.
|
|
62
|
+
|
|
63
|
+
## Structure
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
dcl_core/
|
|
67
|
+
├── chain.py ChainState — append-only chain, content-hash verify()
|
|
68
|
+
└── consensus.py ConsensusRound, compute_super_hash — multi-party consensus
|
|
69
|
+
tests/
|
|
70
|
+
└── test_fixes.py Regression tests for both fixes above
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install -e .
|
|
77
|
+
python -m pytest tests/ -v
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from dcl_core import ChainState
|
|
82
|
+
|
|
83
|
+
chain = ChainState("audit.db")
|
|
84
|
+
tx_hash, idx = chain.append(
|
|
85
|
+
verdict="COMMIT", input_hash="0xabc...", policy_hash="0xdef...",
|
|
86
|
+
agent_id="agent-1", reason="policy checks passed", confidence=0.95,
|
|
87
|
+
task_type="generation",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
clean, bad_index, reason = chain.verify()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from dcl_core import ConsensusRound, verify_super_hash
|
|
95
|
+
|
|
96
|
+
round_ = ConsensusRound(expected_parties={"org-a", "org-b", "org-c"})
|
|
97
|
+
round_.submit("org-a", h_a)
|
|
98
|
+
round_.submit("org-b", h_b)
|
|
99
|
+
round_.submit("org-c", h_c)
|
|
100
|
+
super_hash, contributions = round_.seal()
|
|
101
|
+
assert verify_super_hash(contributions, super_hash)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Scope
|
|
105
|
+
|
|
106
|
+
This repo publishes the **protocol layer only** — record format, hash-chain
|
|
107
|
+
verification, and consensus combination. Scoring/policy engines (behavioral
|
|
108
|
+
fingerprinting, epistemic validation thresholds, reputation weighting) stay
|
|
109
|
+
in separate, closed modules that build on top of this.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|
dcl_core-0.3.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# DCL Core
|
|
2
|
+
|
|
3
|
+
Tamper-evident record chain + multi-party consensus primitives for AI agent
|
|
4
|
+
audit systems. Part of the Deterministic Commitment Layer / Leibniz Layer™
|
|
5
|
+
ecosystem by Fronesis Labs.
|
|
6
|
+
|
|
7
|
+
This is a clean-history consolidation: the single-agent chain logic
|
|
8
|
+
previously duplicated inside `dcl-webhook`, and the multi-agent consensus
|
|
9
|
+
logic from `dcl-v2`, unified into one module with two known issues fixed.
|
|
10
|
+
|
|
11
|
+
## What changed vs the previous implementations
|
|
12
|
+
|
|
13
|
+
### 1. `ChainState.verify()` now detects content tampering
|
|
14
|
+
|
|
15
|
+
The chain logic previously embedded in `dcl-webhook/dcl_core.py` only
|
|
16
|
+
checked that `prev_hash` pointers linked correctly between rows. It never
|
|
17
|
+
recomputed a row's hash from its own stored fields. That meant a party with
|
|
18
|
+
direct database access could edit `verdict`, `confidence`, or `reason` on
|
|
19
|
+
an existing row — without touching `tx_hash`/`prev_hash` — and `verify()`
|
|
20
|
+
would still report the chain as clean. This defeated the core tamper-evident
|
|
21
|
+
claim.
|
|
22
|
+
|
|
23
|
+
`verify()` here recomputes each row's hash from all of its stored fields
|
|
24
|
+
and compares it against the stored `tx_hash`. Editing any field now breaks
|
|
25
|
+
verification. See `tests/test_fixes.py::test_detects_content_tamper_not_just_link_break`.
|
|
26
|
+
|
|
27
|
+
### 2. Super-Hash consensus no longer uses XOR
|
|
28
|
+
|
|
29
|
+
`dcl-v2`'s original formula was `H*_t = hash(h_t1 ⊕ h_t2 ⊕ ... ⊕ h_tM)`.
|
|
30
|
+
XOR is commutative and reversible: a party submitting last (or able to
|
|
31
|
+
observe the running combination) can solve for a contribution that forces
|
|
32
|
+
the final Super-Hash to any target value, without breaking any hash
|
|
33
|
+
function — cheap arithmetic forgery, not a cryptographic break.
|
|
34
|
+
|
|
35
|
+
`compute_super_hash()` instead sorts contributions by party ID and hashes
|
|
36
|
+
their length-prefixed concatenation. Order no longer matters (no
|
|
37
|
+
last-mover advantage) and the XOR-forgery trick no longer works. See
|
|
38
|
+
`tests/test_fixes.py::test_super_hash_xor_forgery_no_longer_trivial`.
|
|
39
|
+
|
|
40
|
+
## Structure
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
dcl_core/
|
|
44
|
+
├── chain.py ChainState — append-only chain, content-hash verify()
|
|
45
|
+
└── consensus.py ConsensusRound, compute_super_hash — multi-party consensus
|
|
46
|
+
tests/
|
|
47
|
+
└── test_fixes.py Regression tests for both fixes above
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install -e .
|
|
54
|
+
python -m pytest tests/ -v
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from dcl_core import ChainState
|
|
59
|
+
|
|
60
|
+
chain = ChainState("audit.db")
|
|
61
|
+
tx_hash, idx = chain.append(
|
|
62
|
+
verdict="COMMIT", input_hash="0xabc...", policy_hash="0xdef...",
|
|
63
|
+
agent_id="agent-1", reason="policy checks passed", confidence=0.95,
|
|
64
|
+
task_type="generation",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
clean, bad_index, reason = chain.verify()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from dcl_core import ConsensusRound, verify_super_hash
|
|
72
|
+
|
|
73
|
+
round_ = ConsensusRound(expected_parties={"org-a", "org-b", "org-c"})
|
|
74
|
+
round_.submit("org-a", h_a)
|
|
75
|
+
round_.submit("org-b", h_b)
|
|
76
|
+
round_.submit("org-c", h_c)
|
|
77
|
+
super_hash, contributions = round_.seal()
|
|
78
|
+
assert verify_super_hash(contributions, super_hash)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Scope
|
|
82
|
+
|
|
83
|
+
This repo publishes the **protocol layer only** — record format, hash-chain
|
|
84
|
+
verification, and consensus combination. Scoring/policy engines (behavioral
|
|
85
|
+
fingerprinting, epistemic validation thresholds, reputation weighting) stay
|
|
86
|
+
in separate, closed modules that build on top of this.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .chain import ChainState, TamperDetectedError, sha256hex
|
|
2
|
+
from .consensus import ConsensusRound, compute_super_hash, verify_super_hash
|
|
3
|
+
from .verify import GENESIS, VerifyResult, canonical_content, recompute_tx_hash, verify_chain
|
|
4
|
+
from .seal import Seal, format_seal
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ChainState",
|
|
8
|
+
"TamperDetectedError",
|
|
9
|
+
"sha256hex",
|
|
10
|
+
"ConsensusRound",
|
|
11
|
+
"compute_super_hash",
|
|
12
|
+
"verify_super_hash",
|
|
13
|
+
"GENESIS",
|
|
14
|
+
"VerifyResult",
|
|
15
|
+
"canonical_content",
|
|
16
|
+
"recompute_tx_hash",
|
|
17
|
+
"verify_chain",
|
|
18
|
+
"Seal",
|
|
19
|
+
"format_seal",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dcl_core.chain — append-only tamper-evident record chain.
|
|
3
|
+
|
|
4
|
+
Fix vs the version previously embedded in dcl-webhook's dcl_core.py:
|
|
5
|
+
- verify() now RECOMPUTES tx_hash from each row's stored content and
|
|
6
|
+
compares it to the stored tx_hash. The old version only checked that
|
|
7
|
+
prev_hash pointers were linked correctly, which meant an attacker with
|
|
8
|
+
direct DB access could edit a row's `verdict`/`confidence`/`reason`
|
|
9
|
+
without touching tx_hash/prev_hash, and verify() would still report the
|
|
10
|
+
chain as clean. That defeated the core "tamper-evident" claim.
|
|
11
|
+
- The timestamp used inside the hashed content is now the SAME value
|
|
12
|
+
stored in the `timestamp` column (previously two separate time.time()
|
|
13
|
+
calls were made, one for hashing and one for storage, making the hash
|
|
14
|
+
permanently unreproducible from stored fields).
|
|
15
|
+
|
|
16
|
+
Patch (this file): added export() — a plain metadata dump of the full
|
|
17
|
+
chain, oldest first. This is read-only access to already-stored fields
|
|
18
|
+
(the same shape get_by_tx() returns), not scoring/policy logic, so it
|
|
19
|
+
belongs in the open protocol layer. webhook_server.py's GET /chain/export
|
|
20
|
+
was calling _chain.export() against a ChainState that never defined it —
|
|
21
|
+
this closes that gap.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import sqlite3
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import Optional, Tuple
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha256hex(s: str) -> str:
|
|
33
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TamperDetectedError(Exception):
|
|
37
|
+
"""Raised when a stored record's hash does not match its recomputed hash."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, index: int, reason: str):
|
|
40
|
+
self.index = index
|
|
41
|
+
self.reason = reason
|
|
42
|
+
super().__init__(f"Tamper detected at index {index}: {reason}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChainState:
|
|
46
|
+
"""SQLite-backed tamper-evident chain. Stores METADATA ONLY — never raw content."""
|
|
47
|
+
|
|
48
|
+
GENESIS = "0" * 64
|
|
49
|
+
HASH_LEN = 64 # full sha256 hex length; do not truncate (previous version cut to 32)
|
|
50
|
+
|
|
51
|
+
def __init__(self, db_path: str = "dcl_chain.db"):
|
|
52
|
+
self._lock = threading.RLock() # same-process thread safety
|
|
53
|
+
self._conn = sqlite3.connect(db_path, timeout=30.0, check_same_thread=False)
|
|
54
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
55
|
+
self._conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
CREATE TABLE IF NOT EXISTS chain (
|
|
58
|
+
idx INTEGER PRIMARY KEY,
|
|
59
|
+
tx_hash TEXT UNIQUE NOT NULL,
|
|
60
|
+
prev_hash TEXT NOT NULL,
|
|
61
|
+
verdict TEXT NOT NULL,
|
|
62
|
+
input_hash TEXT NOT NULL,
|
|
63
|
+
policy_hash TEXT NOT NULL,
|
|
64
|
+
agent_id TEXT NOT NULL,
|
|
65
|
+
reason TEXT NOT NULL,
|
|
66
|
+
confidence REAL NOT NULL,
|
|
67
|
+
task_type TEXT NOT NULL,
|
|
68
|
+
timestamp REAL NOT NULL,
|
|
69
|
+
drift_context TEXT NOT NULL
|
|
70
|
+
)
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
73
|
+
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_tx_hash ON chain(tx_hash)")
|
|
74
|
+
self._conn.commit()
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _content_for_hash(
|
|
78
|
+
idx: int,
|
|
79
|
+
verdict: str,
|
|
80
|
+
input_hash: str,
|
|
81
|
+
policy_hash: str,
|
|
82
|
+
prev_hash: str,
|
|
83
|
+
agent_id: str,
|
|
84
|
+
reason: str,
|
|
85
|
+
confidence: float,
|
|
86
|
+
task_type: str,
|
|
87
|
+
timestamp: float,
|
|
88
|
+
drift_context_json: str,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Canonical, order-fixed serialization of everything that must be tamper-evident.
|
|
91
|
+
|
|
92
|
+
Every field that can be independently edited in the DB is included here.
|
|
93
|
+
The old version only hashed idx/verdict/input_hash/policy_hash/prev_hash/time —
|
|
94
|
+
agent_id, reason, confidence, task_type, and drift_context were NOT part of the
|
|
95
|
+
hash, so editing any of those fields directly in SQLite was invisible to verify().
|
|
96
|
+
"""
|
|
97
|
+
return "|".join(
|
|
98
|
+
[
|
|
99
|
+
str(idx),
|
|
100
|
+
verdict,
|
|
101
|
+
input_hash,
|
|
102
|
+
policy_hash,
|
|
103
|
+
prev_hash,
|
|
104
|
+
agent_id,
|
|
105
|
+
reason,
|
|
106
|
+
f"{confidence:.6f}",
|
|
107
|
+
task_type,
|
|
108
|
+
f"{timestamp:.6f}",
|
|
109
|
+
drift_context_json,
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def append(
|
|
114
|
+
self,
|
|
115
|
+
verdict: str,
|
|
116
|
+
input_hash: str,
|
|
117
|
+
policy_hash: str,
|
|
118
|
+
agent_id: str,
|
|
119
|
+
reason: str,
|
|
120
|
+
confidence: float,
|
|
121
|
+
task_type: str,
|
|
122
|
+
drift_context: Optional[dict] = None,
|
|
123
|
+
) -> Tuple[str, int]:
|
|
124
|
+
# BEGIN IMMEDIATE grabs the SQLite write-lock before the SELECT, not
|
|
125
|
+
# after — this is what actually closes the race, not just the
|
|
126
|
+
# in-process RLock below. mcp_server.py and webhook_server.py are
|
|
127
|
+
# separate PM2 processes sharing this same db file; without this,
|
|
128
|
+
# two near-simultaneous appends (one MCP tool call, one REST call)
|
|
129
|
+
# can both read the same MAX(idx)/prev_hash and then collide on the
|
|
130
|
+
# INSERT (idx is a PRIMARY KEY), crashing the second caller's request
|
|
131
|
+
# instead of just making it wait its turn.
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
134
|
+
try:
|
|
135
|
+
last = self._conn.execute(
|
|
136
|
+
"SELECT tx_hash FROM chain ORDER BY idx DESC LIMIT 1"
|
|
137
|
+
).fetchone()
|
|
138
|
+
prev_hash = last[0] if last else self.GENESIS
|
|
139
|
+
new_idx = self._conn.execute(
|
|
140
|
+
"SELECT COALESCE(MAX(idx), -1) + 1 FROM chain"
|
|
141
|
+
).fetchone()[0]
|
|
142
|
+
|
|
143
|
+
timestamp = time.time()
|
|
144
|
+
drift_context = drift_context or {}
|
|
145
|
+
drift_context_json = json.dumps(drift_context, sort_keys=True)
|
|
146
|
+
|
|
147
|
+
content = self._content_for_hash(
|
|
148
|
+
new_idx, verdict, input_hash, policy_hash, prev_hash,
|
|
149
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context_json,
|
|
150
|
+
)
|
|
151
|
+
tx_hash = "0x" + sha256hex(content) # full 64 hex chars, not truncated
|
|
152
|
+
|
|
153
|
+
self._conn.execute(
|
|
154
|
+
"""
|
|
155
|
+
INSERT INTO chain (idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
156
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
158
|
+
""",
|
|
159
|
+
(new_idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
160
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context_json),
|
|
161
|
+
)
|
|
162
|
+
self._conn.commit()
|
|
163
|
+
return tx_hash, new_idx
|
|
164
|
+
except Exception:
|
|
165
|
+
self._conn.rollback()
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
def get_by_tx(self, tx_hash: str) -> Optional[dict]:
|
|
169
|
+
row = self._conn.execute(
|
|
170
|
+
"""
|
|
171
|
+
SELECT idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
172
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context
|
|
173
|
+
FROM chain WHERE tx_hash = ?
|
|
174
|
+
""",
|
|
175
|
+
(tx_hash,),
|
|
176
|
+
).fetchone()
|
|
177
|
+
if not row:
|
|
178
|
+
return None
|
|
179
|
+
return self._row_to_dict(row)
|
|
180
|
+
|
|
181
|
+
def export(self) -> list:
|
|
182
|
+
"""Full chain dump, oldest first. Metadata only — never raw content.
|
|
183
|
+
|
|
184
|
+
Same field shape as get_by_tx(), for every row in the chain. Read-only
|
|
185
|
+
access to already-stored data; not scoring/policy logic, so it stays
|
|
186
|
+
in the open protocol layer alongside append()/get_by_tx()/verify().
|
|
187
|
+
"""
|
|
188
|
+
rows = self._conn.execute(
|
|
189
|
+
"""
|
|
190
|
+
SELECT idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
191
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context
|
|
192
|
+
FROM chain ORDER BY idx
|
|
193
|
+
"""
|
|
194
|
+
).fetchall()
|
|
195
|
+
return [self._row_to_dict(row) for row in rows]
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _row_to_dict(row) -> dict:
|
|
199
|
+
return {
|
|
200
|
+
"index": row[0], "tx_hash": row[1], "prev_hash": row[2], "verdict": row[3],
|
|
201
|
+
"input_hash": row[4], "policy_hash": row[5], "agent_id": row[6], "reason": row[7],
|
|
202
|
+
"confidence": row[8], "task_type": row[9], "timestamp": row[10],
|
|
203
|
+
"drift_context": json.loads(row[11]),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def verify(self) -> Tuple[bool, Optional[int], Optional[str]]:
|
|
207
|
+
"""Full integrity check: link continuity AND content-hash recomputation.
|
|
208
|
+
|
|
209
|
+
Returns (is_clean, first_bad_index_or_None, reason_or_None).
|
|
210
|
+
"""
|
|
211
|
+
rows = self._conn.execute(
|
|
212
|
+
"""
|
|
213
|
+
SELECT idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
214
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context
|
|
215
|
+
FROM chain ORDER BY idx
|
|
216
|
+
"""
|
|
217
|
+
).fetchall()
|
|
218
|
+
|
|
219
|
+
expected_prev = self.GENESIS
|
|
220
|
+
for row in rows:
|
|
221
|
+
(idx, tx_hash, prev_hash, verdict, input_hash, policy_hash,
|
|
222
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context_json) = row
|
|
223
|
+
|
|
224
|
+
# 1. Chain linkage
|
|
225
|
+
if prev_hash != expected_prev:
|
|
226
|
+
return False, idx, "prev_hash does not match preceding tx_hash (link broken)"
|
|
227
|
+
|
|
228
|
+
# 2. Content integrity — recompute and compare
|
|
229
|
+
recomputed_content = self._content_for_hash(
|
|
230
|
+
idx, verdict, input_hash, policy_hash, prev_hash,
|
|
231
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context_json,
|
|
232
|
+
)
|
|
233
|
+
recomputed_hash = "0x" + sha256hex(recomputed_content)
|
|
234
|
+
if recomputed_hash != tx_hash:
|
|
235
|
+
return False, idx, "stored tx_hash does not match recomputed hash of row content (row was edited)"
|
|
236
|
+
|
|
237
|
+
expected_prev = tx_hash
|
|
238
|
+
|
|
239
|
+
return True, None, None
|
|
240
|
+
|
|
241
|
+
def __len__(self):
|
|
242
|
+
return self._conn.execute("SELECT COUNT(*) FROM chain").fetchone()[0]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dcl_core.consensus — cross-organizational Super-Hash consensus (from dcl-v2).
|
|
3
|
+
|
|
4
|
+
Fix vs the original formula H*_t = hash(h_t1 XOR h_t2 XOR ... XOR h_tM):
|
|
5
|
+
|
|
6
|
+
XOR is commutative and reversible. Any party that contributes last (or that
|
|
7
|
+
can observe the running combination before submitting) can solve for a
|
|
8
|
+
value h_tM such that the final H*_t equals whatever they want — without
|
|
9
|
+
breaking any individual party's hash function. That means a dishonest party
|
|
10
|
+
could forge apparent consensus cheaply, via arithmetic on XOR, not via a
|
|
11
|
+
cryptographic break.
|
|
12
|
+
|
|
13
|
+
Fix: combine contributions by hashing their SORTED, length-prefixed
|
|
14
|
+
concatenation instead of XOR-ing them. Sorting makes the result independent
|
|
15
|
+
of submission order (so no party gains an advantage by going last), and
|
|
16
|
+
concatenation removes the XOR-invertibility problem. This is a Merkle-style
|
|
17
|
+
combine, not full tree construction — sufficient for a fixed, known set of M
|
|
18
|
+
parties; swap in a real Merkle tree if M grows large or is dynamic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
from typing import Dict, Tuple
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256hex(s: str) -> str:
|
|
26
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_super_hash(party_hashes: Dict[str, str]) -> str:
|
|
30
|
+
"""Combine per-party hashes into a single cross-organizational commitment.
|
|
31
|
+
|
|
32
|
+
party_hashes: mapping of party_id -> that party's local hash (h_ti) for
|
|
33
|
+
timestep t. Order of insertion does not matter — contributions are
|
|
34
|
+
sorted by party_id before combining, so no party can bias the result by
|
|
35
|
+
controlling submission order.
|
|
36
|
+
"""
|
|
37
|
+
if not party_hashes:
|
|
38
|
+
raise ValueError("compute_super_hash requires at least one party contribution")
|
|
39
|
+
|
|
40
|
+
# Sort by party_id for order-independence, then length-prefix each hash
|
|
41
|
+
# to prevent ambiguous concatenation (e.g. "ab"+"c" vs "a"+"bc").
|
|
42
|
+
parts = []
|
|
43
|
+
for party_id in sorted(party_hashes.keys()):
|
|
44
|
+
h = party_hashes[party_id]
|
|
45
|
+
parts.append(f"{len(party_id)}:{party_id}:{len(h)}:{h}")
|
|
46
|
+
|
|
47
|
+
combined = "|".join(parts)
|
|
48
|
+
return "0x" + sha256hex(combined)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_super_hash(party_hashes: Dict[str, str], claimed_super_hash: str) -> bool:
|
|
52
|
+
"""Recompute the Super-Hash from party contributions and compare."""
|
|
53
|
+
return compute_super_hash(party_hashes) == claimed_super_hash
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConsensusRound:
|
|
57
|
+
"""Collects per-party hash contributions for one timestep and seals them
|
|
58
|
+
only once all expected parties have submitted (commit-reveal shape:
|
|
59
|
+
collect first, combine once, never recombine on partial data)."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, expected_parties: set):
|
|
62
|
+
self.expected_parties = set(expected_parties)
|
|
63
|
+
self._contributions: Dict[str, str] = {}
|
|
64
|
+
self._sealed_super_hash: str | None = None
|
|
65
|
+
|
|
66
|
+
def submit(self, party_id: str, h_t: str) -> None:
|
|
67
|
+
if self._sealed_super_hash is not None:
|
|
68
|
+
raise RuntimeError("round already sealed; cannot accept further contributions")
|
|
69
|
+
if party_id not in self.expected_parties:
|
|
70
|
+
raise ValueError(f"unexpected party_id: {party_id}")
|
|
71
|
+
self._contributions[party_id] = h_t
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_complete(self) -> bool:
|
|
75
|
+
return set(self._contributions.keys()) == self.expected_parties
|
|
76
|
+
|
|
77
|
+
def seal(self) -> Tuple[str, Dict[str, str]]:
|
|
78
|
+
if not self.is_complete:
|
|
79
|
+
missing = self.expected_parties - set(self._contributions.keys())
|
|
80
|
+
raise RuntimeError(f"cannot seal: missing contributions from {missing}")
|
|
81
|
+
self._sealed_super_hash = compute_super_hash(self._contributions)
|
|
82
|
+
return self._sealed_super_hash, dict(self._contributions)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dcl_core.seal — human-readable verification seal formatting.
|
|
3
|
+
|
|
4
|
+
Pure presentation over already-public fields (tx_hash, input_hash,
|
|
5
|
+
timestamp). No scoring/policy logic, no network call — mirrors
|
|
6
|
+
`formatSeal()` in @fronesis-labs/dcl-sdk (TypeScript) exactly, including
|
|
7
|
+
the white-label options, so other DCL-protocol implementers aren't stuck
|
|
8
|
+
with Fronesis Labs' branding.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Seal(TypedDict):
|
|
16
|
+
seal_text: str
|
|
17
|
+
verify_url: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_BRAND = "Leibniz Layer | Fronesis Labs"
|
|
21
|
+
DEFAULT_VERIFY_BASE_URL = "https://x402.fronesislabs.com/verify"
|
|
22
|
+
DEFAULT_NETWORK = "Base Mainnet"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _strip_hex_prefix(s: str) -> str:
|
|
26
|
+
return s[2:] if s.startswith("0x") else s
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_sealed_timestamp(unix_seconds: float) -> str:
|
|
30
|
+
return datetime.fromtimestamp(unix_seconds, tz=timezone.utc).strftime(
|
|
31
|
+
"%Y-%m-%d %H:%M UTC"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_seal(
|
|
36
|
+
tx_hash: str,
|
|
37
|
+
input_hash: str,
|
|
38
|
+
timestamp: float,
|
|
39
|
+
brand: str = DEFAULT_BRAND,
|
|
40
|
+
verify_base_url: str = DEFAULT_VERIFY_BASE_URL,
|
|
41
|
+
network: str = DEFAULT_NETWORK,
|
|
42
|
+
) -> Seal:
|
|
43
|
+
"""Formats a human-readable "Verified by..." seal.
|
|
44
|
+
|
|
45
|
+
Does not fetch or verify anything itself — call verify_chain() /
|
|
46
|
+
recompute_tx_hash() first if you want an integrity guarantee behind
|
|
47
|
+
the seal you're displaying. Set network="" to omit the network suffix.
|
|
48
|
+
"""
|
|
49
|
+
hash_display = _strip_hex_prefix(tx_hash)
|
|
50
|
+
intent_display = _strip_hex_prefix(input_hash)
|
|
51
|
+
sealed = _format_sealed_timestamp(timestamp)
|
|
52
|
+
verify_url = f"{verify_base_url}/{hash_display}"
|
|
53
|
+
network_suffix = f" — {network}" if network else ""
|
|
54
|
+
|
|
55
|
+
seal_text = "\n".join(
|
|
56
|
+
[
|
|
57
|
+
f"🔒 Verified by {brand}",
|
|
58
|
+
f"Hash: {hash_display}",
|
|
59
|
+
f"Intent: {intent_display}",
|
|
60
|
+
f"Sealed: {sealed}{network_suffix}",
|
|
61
|
+
f"Verify: {verify_url}",
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return {"seal_text": seal_text, "verify_url": verify_url}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dcl_core.verify — standalone, offline chain verification.
|
|
3
|
+
|
|
4
|
+
This is the Python counterpart to `verifyChain()` / `recomputeTxHash()` /
|
|
5
|
+
`canonicalContent()` in `@fronesis-labs/dcl-sdk` (TypeScript). It operates
|
|
6
|
+
on plain dicts (the same shape `ChainState.export()` / `get_by_tx()`
|
|
7
|
+
return) — no SQLite, no network. Anyone holding a chain export (their own,
|
|
8
|
+
or fetched from someone else's server) can independently confirm it wasn't
|
|
9
|
+
tampered with, without trusting the server that handed it to them.
|
|
10
|
+
|
|
11
|
+
Implements PROTOCOL.md §1 (canonical content string), §2 (chain linking),
|
|
12
|
+
§3 (verification algorithm). `canonical_content()` here and
|
|
13
|
+
`ChainState._content_for_hash()` in chain.py must stay byte-identical —
|
|
14
|
+
that invariant is what `tests/test_verify_matches_chain_state.py` checks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
GENESIS = "0" * 64
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256hex(s: str) -> str:
|
|
26
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def canonical_content(record: dict) -> str:
|
|
30
|
+
"""§1 — the exact pipe-delimited string that gets SHA-256 hashed.
|
|
31
|
+
|
|
32
|
+
`record` must have: index, verdict, input_hash, policy_hash, prev_hash,
|
|
33
|
+
agent_id, reason, confidence, task_type, timestamp, drift_context.
|
|
34
|
+
"""
|
|
35
|
+
drift_context = record.get("drift_context") or {}
|
|
36
|
+
drift_context_json = json.dumps(drift_context, sort_keys=True)
|
|
37
|
+
return "|".join(
|
|
38
|
+
[
|
|
39
|
+
str(record["index"]),
|
|
40
|
+
record["verdict"],
|
|
41
|
+
record["input_hash"],
|
|
42
|
+
record["policy_hash"],
|
|
43
|
+
record["prev_hash"],
|
|
44
|
+
record["agent_id"],
|
|
45
|
+
record["reason"],
|
|
46
|
+
f"{record['confidence']:.6f}",
|
|
47
|
+
record["task_type"],
|
|
48
|
+
f"{record['timestamp']:.6f}",
|
|
49
|
+
drift_context_json,
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def recompute_tx_hash(record: dict) -> str:
|
|
55
|
+
"""§1.2 — recomputes tx_hash for a single record from its stored fields."""
|
|
56
|
+
return "0x" + sha256hex(canonical_content(record))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class VerifyResult:
|
|
61
|
+
clean: bool
|
|
62
|
+
bad_index: Optional[int] = None
|
|
63
|
+
reason: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_chain(records: list) -> VerifyResult:
|
|
67
|
+
"""§3 — full verification: chain-link continuity AND per-record
|
|
68
|
+
content-hash recomputation. Catches direct-DB edits that a server's
|
|
69
|
+
own /audit response can't be trusted to admit to.
|
|
70
|
+
"""
|
|
71
|
+
sorted_records = sorted(records, key=lambda r: r["index"])
|
|
72
|
+
expected_prev = GENESIS
|
|
73
|
+
|
|
74
|
+
for r in sorted_records:
|
|
75
|
+
if r["prev_hash"] != expected_prev:
|
|
76
|
+
return VerifyResult(
|
|
77
|
+
clean=False,
|
|
78
|
+
bad_index=r["index"],
|
|
79
|
+
reason="prev_hash does not match preceding tx_hash (link broken)",
|
|
80
|
+
)
|
|
81
|
+
recomputed = recompute_tx_hash(r)
|
|
82
|
+
if recomputed != r["tx_hash"]:
|
|
83
|
+
return VerifyResult(
|
|
84
|
+
clean=False,
|
|
85
|
+
bad_index=r["index"],
|
|
86
|
+
reason="stored tx_hash does not match recomputed hash of row content (row was edited)",
|
|
87
|
+
)
|
|
88
|
+
expected_prev = r["tx_hash"]
|
|
89
|
+
|
|
90
|
+
return VerifyResult(clean=True)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dcl-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Tamper-evident record chain, offline chain verification, and multi-party consensus primitives for AI agent audit systems (DCL / Leibniz Layer protocol).
|
|
5
|
+
Author: Fronesis Labs
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://mcp.fronesislabs.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Fronesis-Labs/dcl-core
|
|
9
|
+
Project-URL: Protocol, https://github.com/Fronesis-Labs/dcl-sdk/blob/main/PROTOCOL.md
|
|
10
|
+
Keywords: mcp,model-context-protocol,audit,hash-chain,ai-agents,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# DCL Core
|
|
25
|
+
|
|
26
|
+
Tamper-evident record chain + multi-party consensus primitives for AI agent
|
|
27
|
+
audit systems. Part of the Deterministic Commitment Layer / Leibniz Layer™
|
|
28
|
+
ecosystem by Fronesis Labs.
|
|
29
|
+
|
|
30
|
+
This is a clean-history consolidation: the single-agent chain logic
|
|
31
|
+
previously duplicated inside `dcl-webhook`, and the multi-agent consensus
|
|
32
|
+
logic from `dcl-v2`, unified into one module with two known issues fixed.
|
|
33
|
+
|
|
34
|
+
## What changed vs the previous implementations
|
|
35
|
+
|
|
36
|
+
### 1. `ChainState.verify()` now detects content tampering
|
|
37
|
+
|
|
38
|
+
The chain logic previously embedded in `dcl-webhook/dcl_core.py` only
|
|
39
|
+
checked that `prev_hash` pointers linked correctly between rows. It never
|
|
40
|
+
recomputed a row's hash from its own stored fields. That meant a party with
|
|
41
|
+
direct database access could edit `verdict`, `confidence`, or `reason` on
|
|
42
|
+
an existing row — without touching `tx_hash`/`prev_hash` — and `verify()`
|
|
43
|
+
would still report the chain as clean. This defeated the core tamper-evident
|
|
44
|
+
claim.
|
|
45
|
+
|
|
46
|
+
`verify()` here recomputes each row's hash from all of its stored fields
|
|
47
|
+
and compares it against the stored `tx_hash`. Editing any field now breaks
|
|
48
|
+
verification. See `tests/test_fixes.py::test_detects_content_tamper_not_just_link_break`.
|
|
49
|
+
|
|
50
|
+
### 2. Super-Hash consensus no longer uses XOR
|
|
51
|
+
|
|
52
|
+
`dcl-v2`'s original formula was `H*_t = hash(h_t1 ⊕ h_t2 ⊕ ... ⊕ h_tM)`.
|
|
53
|
+
XOR is commutative and reversible: a party submitting last (or able to
|
|
54
|
+
observe the running combination) can solve for a contribution that forces
|
|
55
|
+
the final Super-Hash to any target value, without breaking any hash
|
|
56
|
+
function — cheap arithmetic forgery, not a cryptographic break.
|
|
57
|
+
|
|
58
|
+
`compute_super_hash()` instead sorts contributions by party ID and hashes
|
|
59
|
+
their length-prefixed concatenation. Order no longer matters (no
|
|
60
|
+
last-mover advantage) and the XOR-forgery trick no longer works. See
|
|
61
|
+
`tests/test_fixes.py::test_super_hash_xor_forgery_no_longer_trivial`.
|
|
62
|
+
|
|
63
|
+
## Structure
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
dcl_core/
|
|
67
|
+
├── chain.py ChainState — append-only chain, content-hash verify()
|
|
68
|
+
└── consensus.py ConsensusRound, compute_super_hash — multi-party consensus
|
|
69
|
+
tests/
|
|
70
|
+
└── test_fixes.py Regression tests for both fixes above
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install -e .
|
|
77
|
+
python -m pytest tests/ -v
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from dcl_core import ChainState
|
|
82
|
+
|
|
83
|
+
chain = ChainState("audit.db")
|
|
84
|
+
tx_hash, idx = chain.append(
|
|
85
|
+
verdict="COMMIT", input_hash="0xabc...", policy_hash="0xdef...",
|
|
86
|
+
agent_id="agent-1", reason="policy checks passed", confidence=0.95,
|
|
87
|
+
task_type="generation",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
clean, bad_index, reason = chain.verify()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from dcl_core import ConsensusRound, verify_super_hash
|
|
95
|
+
|
|
96
|
+
round_ = ConsensusRound(expected_parties={"org-a", "org-b", "org-c"})
|
|
97
|
+
round_.submit("org-a", h_a)
|
|
98
|
+
round_.submit("org-b", h_b)
|
|
99
|
+
round_.submit("org-c", h_c)
|
|
100
|
+
super_hash, contributions = round_.seal()
|
|
101
|
+
assert verify_super_hash(contributions, super_hash)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Scope
|
|
105
|
+
|
|
106
|
+
This repo publishes the **protocol layer only** — record format, hash-chain
|
|
107
|
+
verification, and consensus combination. Scoring/policy engines (behavioral
|
|
108
|
+
fingerprinting, epistemic validation thresholds, reputation weighting) stay
|
|
109
|
+
in separate, closed modules that build on top of this.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
dcl_core/__init__.py
|
|
5
|
+
dcl_core/chain.py
|
|
6
|
+
dcl_core/consensus.py
|
|
7
|
+
dcl_core/seal.py
|
|
8
|
+
dcl_core/verify.py
|
|
9
|
+
dcl_core.egg-info/PKG-INFO
|
|
10
|
+
dcl_core.egg-info/SOURCES.txt
|
|
11
|
+
dcl_core.egg-info/dependency_links.txt
|
|
12
|
+
dcl_core.egg-info/requires.txt
|
|
13
|
+
dcl_core.egg-info/top_level.txt
|
|
14
|
+
tests/test_fixes.py
|
|
15
|
+
tests/test_verify_matches_chain_state.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dcl_core
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dcl-core"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Tamper-evident record chain, offline chain verification, and multi-party consensus primitives for AI agent audit systems (DCL / Leibniz Layer protocol)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
authors = [{ name = "Fronesis Labs" }]
|
|
14
|
+
keywords = ["mcp", "model-context-protocol", "audit", "hash-chain", "ai-agents", "x402"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Security :: Cryptography",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://mcp.fronesislabs.com"
|
|
26
|
+
Repository = "https://github.com/Fronesis-Labs/dcl-core"
|
|
27
|
+
Protocol = "https://github.com/Fronesis-Labs/dcl-sdk/blob/main/PROTOCOL.md"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7.0"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["dcl_core*"]
|
dcl_core-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Regression tests for the two fixes:
|
|
3
|
+
1. ChainState.verify() must detect content tampering, not just broken links.
|
|
4
|
+
2. Super-Hash consensus must resist the XOR-forgeability attack.
|
|
5
|
+
|
|
6
|
+
Plus a coverage test for export(), added when dcl-webhook's GET /chain/export
|
|
7
|
+
turned out to be calling a method that never existed on ChainState.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from dcl_core.chain import ChainState
|
|
16
|
+
from dcl_core.consensus import compute_super_hash, verify_super_hash, ConsensusRound
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_chain():
|
|
20
|
+
fd, path = tempfile.mkstemp(suffix=".db")
|
|
21
|
+
os.close(fd)
|
|
22
|
+
os.remove(path) # let sqlite create it fresh
|
|
23
|
+
return ChainState(path), path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_clean_chain_verifies():
|
|
27
|
+
chain, path = make_chain()
|
|
28
|
+
chain.append("COMMIT", "ih1", "ph1", "agent-1", "ok", 0.95, "test")
|
|
29
|
+
chain.append("COMMIT", "ih2", "ph1", "agent-1", "ok", 0.90, "test")
|
|
30
|
+
clean, bad_idx, reason = chain.verify()
|
|
31
|
+
assert clean is True
|
|
32
|
+
assert bad_idx is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_detects_content_tamper_not_just_link_break():
|
|
36
|
+
"""This is the core regression: editing a field WITHOUT touching
|
|
37
|
+
tx_hash/prev_hash used to pass the old verify(). It must now fail."""
|
|
38
|
+
chain, path = make_chain()
|
|
39
|
+
chain.append("NO_COMMIT", "ih1", "ph1", "agent-1", "policy violation", 0.10, "test")
|
|
40
|
+
|
|
41
|
+
# Simulate an attacker with direct DB access flipping the verdict,
|
|
42
|
+
# leaving tx_hash and prev_hash untouched.
|
|
43
|
+
conn = chain._conn
|
|
44
|
+
conn.execute("UPDATE chain SET verdict = 'COMMIT', confidence = 0.99 WHERE idx = 0")
|
|
45
|
+
conn.commit()
|
|
46
|
+
|
|
47
|
+
clean, bad_idx, reason = chain.verify()
|
|
48
|
+
assert clean is False
|
|
49
|
+
assert bad_idx == 0
|
|
50
|
+
assert "recomputed hash" in reason
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_detects_broken_link():
|
|
54
|
+
chain, path = make_chain()
|
|
55
|
+
chain.append("COMMIT", "ih1", "ph1", "agent-1", "ok", 0.95, "test")
|
|
56
|
+
chain.append("COMMIT", "ih2", "ph1", "agent-1", "ok", 0.90, "test")
|
|
57
|
+
|
|
58
|
+
conn = chain._conn
|
|
59
|
+
conn.execute("UPDATE chain SET prev_hash = 'deadbeef' WHERE idx = 1")
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
clean, bad_idx, reason = chain.verify()
|
|
63
|
+
assert clean is False
|
|
64
|
+
assert bad_idx == 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_export_returns_full_chain_in_order():
|
|
68
|
+
chain, path = make_chain()
|
|
69
|
+
chain.append("COMMIT", "ih1", "ph1", "agent-1", "ok", 0.95, "test")
|
|
70
|
+
chain.append("NO_COMMIT", "ih2", "ph1", "agent-2", "policy violation", 0.10, "test")
|
|
71
|
+
|
|
72
|
+
dump = chain.export()
|
|
73
|
+
assert len(dump) == 2
|
|
74
|
+
assert [row["index"] for row in dump] == [0, 1]
|
|
75
|
+
assert dump[0]["agent_id"] == "agent-1"
|
|
76
|
+
assert dump[1]["verdict"] == "NO_COMMIT"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_export_matches_get_by_tx_shape():
|
|
80
|
+
chain, path = make_chain()
|
|
81
|
+
tx_hash, _ = chain.append("COMMIT", "ih1", "ph1", "agent-1", "ok", 0.95, "test")
|
|
82
|
+
|
|
83
|
+
exported_row = chain.export()[0]
|
|
84
|
+
fetched_row = chain.get_by_tx(tx_hash)
|
|
85
|
+
assert exported_row == fetched_row
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_super_hash_order_independent():
|
|
89
|
+
contributions = {"org-a": "h_a", "org-b": "h_b", "org-c": "h_c"}
|
|
90
|
+
h1 = compute_super_hash(contributions)
|
|
91
|
+
h2 = compute_super_hash({"org-c": "h_c", "org-a": "h_a", "org-b": "h_b"})
|
|
92
|
+
assert h1 == h2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_super_hash_xor_forgery_no_longer_trivial():
|
|
96
|
+
"""Under the old XOR scheme, a party submitting last could pick h_tM
|
|
97
|
+
such that h_t1 ^ h_t2 ^ ... ^ h_tM equals any target value, by solving
|
|
98
|
+
h_tM = target ^ (h_t1 ^ ... ^ h_t(M-1)). Demonstrate that trick no
|
|
99
|
+
longer forges the new (concatenation-based) Super-Hash."""
|
|
100
|
+
h_a = "aa" * 32
|
|
101
|
+
h_b = "bb" * 32
|
|
102
|
+
target = "11" * 32 # attacker's desired forged super-hash (hex string)
|
|
103
|
+
|
|
104
|
+
# The old XOR-forgery move: compute h_c that would XOR the others to `target`.
|
|
105
|
+
def xor_hex(x: str, y: str) -> str:
|
|
106
|
+
return format(int(x, 16) ^ int(y, 16), f"0{len(x)}x")
|
|
107
|
+
|
|
108
|
+
forged_h_c = xor_hex(xor_hex(h_a, h_b), target)
|
|
109
|
+
|
|
110
|
+
# Under the fixed scheme this forged value does not produce `target`.
|
|
111
|
+
forged_super_hash = compute_super_hash({"org-a": h_a, "org-b": h_b, "org-c": forged_h_c})
|
|
112
|
+
assert forged_super_hash != "0x" + target
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_consensus_round_requires_all_parties_before_sealing():
|
|
116
|
+
round_ = ConsensusRound(expected_parties={"org-a", "org-b"})
|
|
117
|
+
round_.submit("org-a", "h_a")
|
|
118
|
+
assert round_.is_complete is False
|
|
119
|
+
with pytest.raises(RuntimeError):
|
|
120
|
+
round_.seal()
|
|
121
|
+
|
|
122
|
+
round_.submit("org-b", "h_b")
|
|
123
|
+
assert round_.is_complete is True
|
|
124
|
+
super_hash, contributions = round_.seal()
|
|
125
|
+
assert verify_super_hash(contributions, super_hash)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confirms dcl_core.verify.verify_chain() (standalone, offline, dict-based)
|
|
3
|
+
agrees with ChainState.verify() (SQLite-backed, production) on the exact
|
|
4
|
+
same data. If these two ever disagree, cross-language SDK verification
|
|
5
|
+
(TS, and anyone else implementing PROTOCOL.md) silently breaks — this is
|
|
6
|
+
the guard against that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import tempfile
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from dcl_core.chain import ChainState
|
|
13
|
+
from dcl_core.verify import verify_chain, recompute_tx_hash, GENESIS
|
|
14
|
+
from dcl_core.seal import format_seal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_chain(n=5):
|
|
18
|
+
fd, path = tempfile.mkstemp(suffix=".db")
|
|
19
|
+
os.close(fd)
|
|
20
|
+
cs = ChainState(db_path=path)
|
|
21
|
+
for i in range(n):
|
|
22
|
+
cs.append(
|
|
23
|
+
verdict="COMMIT" if i % 2 == 0 else "NO_COMMIT",
|
|
24
|
+
input_hash=f"0x{'a' * 63}{i}",
|
|
25
|
+
policy_hash=f"0x{'b' * 63}{i}",
|
|
26
|
+
agent_id=f"agent-{i}",
|
|
27
|
+
reason=f"reason {i}",
|
|
28
|
+
confidence=0.9 + i * 0.001,
|
|
29
|
+
task_type="test",
|
|
30
|
+
drift_context={"environment": "test", "step": i},
|
|
31
|
+
)
|
|
32
|
+
return cs, path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_standalone_verify_matches_chain_state_on_clean_chain():
|
|
36
|
+
cs, path = _make_chain(5)
|
|
37
|
+
try:
|
|
38
|
+
clean, bad_index, reason = cs.verify()
|
|
39
|
+
assert clean is True
|
|
40
|
+
|
|
41
|
+
records = cs.export()
|
|
42
|
+
result = verify_chain(records)
|
|
43
|
+
assert result.clean is True
|
|
44
|
+
assert result.bad_index is None
|
|
45
|
+
finally:
|
|
46
|
+
os.remove(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_recompute_tx_hash_matches_stored_hash_for_every_record():
|
|
50
|
+
cs, path = _make_chain(3)
|
|
51
|
+
try:
|
|
52
|
+
for record in cs.export():
|
|
53
|
+
assert recompute_tx_hash(record) == record["tx_hash"]
|
|
54
|
+
finally:
|
|
55
|
+
os.remove(path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_standalone_verify_catches_tampered_row():
|
|
59
|
+
cs, path = _make_chain(4)
|
|
60
|
+
try:
|
|
61
|
+
records = cs.export()
|
|
62
|
+
records[2]["confidence"] = 0.111111 # tamper without touching tx_hash
|
|
63
|
+
|
|
64
|
+
result = verify_chain(records)
|
|
65
|
+
assert result.clean is False
|
|
66
|
+
assert result.bad_index == 2
|
|
67
|
+
assert "content hash" in result.reason or "recomputed" in result.reason
|
|
68
|
+
finally:
|
|
69
|
+
os.remove(path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_genesis_prev_hash_constant_matches_chain_state():
|
|
73
|
+
assert GENESIS == ChainState.GENESIS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_format_seal_basic_shape():
|
|
77
|
+
seal = format_seal(
|
|
78
|
+
tx_hash="0x" + "a" * 64,
|
|
79
|
+
input_hash="0x" + "b" * 64,
|
|
80
|
+
timestamp=1754400000.123456,
|
|
81
|
+
)
|
|
82
|
+
assert seal["seal_text"].startswith("🔒 Verified by Leibniz Layer | Fronesis Labs")
|
|
83
|
+
assert seal["verify_url"] == "https://x402.fronesislabs.com/verify/" + "a" * 64
|
|
84
|
+
assert "2025-08-05" in seal["seal_text"] or "2025" in seal["seal_text"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_format_seal_white_label_omits_network():
|
|
88
|
+
seal = format_seal(
|
|
89
|
+
tx_hash="0x" + "c" * 64,
|
|
90
|
+
input_hash="0x" + "d" * 64,
|
|
91
|
+
timestamp=1754400000.0,
|
|
92
|
+
brand="My Audit Layer",
|
|
93
|
+
verify_base_url="https://example.com/verify",
|
|
94
|
+
network="",
|
|
95
|
+
)
|
|
96
|
+
assert "My Audit Layer" in seal["seal_text"]
|
|
97
|
+
assert "—" not in seal["seal_text"]
|
|
98
|
+
assert seal["verify_url"] == "https://example.com/verify/" + "c" * 64
|