wrap: chain verify + manifest verify + LICENSE + final docs
- internal/chain: end-to-end chain verification. Walks head → genesis,
verifies every cert (Ed25519 or STM as appropriate), and checks
continuity at every boundary:
epoch: same or +1 from previous
hash: current.previous_hash == previous.hash
AVK: same epoch → equal aggregate_verification_key
new epoch → matches previous.protocol_message.next_aggregate_verification_key
- cmd: 'verify chain' subcommand + 'verify manifest <dir>' for SHA-checking
downloaded immutable files
- internal/manifest: per-file SHA-256 verification against the digests.json
shipped in the snapshot's digests archive
- MCP: 8th tool 'mithril_verify_chain' for agent-driven full-chain verify
- README: complete rewrite — status table, architecture, gotchas, MCP
tool surface, exit code contract, build instructions
- LICENSE: Apache-2.0 (matches upstream Mithril)
Verified end to end against live networks:
preprod chain 90 certs (89 STM + 1 genesis) 1124 wins ✓
mainnet chain 89 certs (88 STM + 1 genesis) 210921 wins ✓
That's the wrap. Pure-Go consensus-correct Mithril client, single 10 MB
static binary, MCP-native, no CGo, no upstream Rust runtime.
This commit is contained in:
parent
920d7cf177
commit
599085eaa9
8 changed files with 905 additions and 90 deletions
201
LICENSE
Normal file
201
LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for describing the origin of the Work and
|
||||||
|
reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Support. While redistributing the Work or
|
||||||
|
Derivative Works thereof, You may choose to offer, and charge a
|
||||||
|
fee for, acceptance of support, warranty, indemnity, or other
|
||||||
|
liability obligations and/or rights consistent with this License.
|
||||||
|
However, in accepting such obligations, You may act only on Your
|
||||||
|
own behalf and on Your sole responsibility, not on behalf of any
|
||||||
|
other Contributor, and only if You agree to indemnify, defend,
|
||||||
|
and hold each Contributor harmless for any liability incurred by,
|
||||||
|
or claims asserted against, such Contributor by reason of your
|
||||||
|
accepting any such warranty or support.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Sulkta Coop
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied. See the License for the specific language governing
|
||||||
|
permissions and limitations under the License.
|
||||||
201
README.md
201
README.md
|
|
@ -7,106 +7,107 @@ node bootstrap the chain from a cryptographically-verified snapshot
|
||||||
instead of replaying every block from genesis.
|
instead of replaying every block from genesis.
|
||||||
|
|
||||||
The official [`mithril-client`](https://github.com/input-output-hk/mithril)
|
The official [`mithril-client`](https://github.com/input-output-hk/mithril)
|
||||||
is Rust. This project is a pure-Go reimplementation that produces a single
|
is Rust. `mithril-go` is a pure-Go reimplementation that produces a single
|
||||||
static binary with no runtime dependencies — useful for:
|
static binary with no CGo, no upstream Rust runtime, and an MCP-native
|
||||||
|
tool surface for AI agents — useful for:
|
||||||
|
|
||||||
- Embedding a Mithril bootstrap into Go-based Cardano tooling
|
- Embedding Mithril bootstrap into Go-based Cardano tooling (alongside
|
||||||
(alongside `gouroboros`, `dingo`, and friends)
|
`gouroboros`, `dingo`, etc.)
|
||||||
- Running on constrained ARM/embedded targets where shipping the Rust
|
- Constrained ARM/embedded targets where shipping the Rust binary + its
|
||||||
binary + its deps is overkill
|
deps is overkill
|
||||||
- Operators who prefer a single `go install`-able helper
|
- Operators who prefer a single `go install`-able helper
|
||||||
|
- AI agents (Claude Code, Cursor, Zed, ...) that want to verify Mithril
|
||||||
|
certs as a callable tool
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Full Mithril verification working — genesis Ed25519 AND STM BLS12-381 — against live mainnet and preprod.**
|
**Working consensus-correct verification against live mainnet and preprod.**
|
||||||
|
|
||||||
| Piece | Status |
|
| Capability | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Aggregator REST client | ✅ list, get, cert, chain |
|
| Aggregator REST client | ✅ list, show, cert, walk-chain |
|
||||||
| `list` / `show` / `info` / `cert` commands | ✅ mainnet + preprod |
|
| Resumable HTTP download (SHA hook + progress) | ✅ |
|
||||||
| Resumable HTTP download (single stream, SHA hook) | ✅ |
|
|
||||||
| Streamed zstd+tar extract (tar-slip defended) | ✅ |
|
| Streamed zstd+tar extract (tar-slip defended) | ✅ |
|
||||||
| `download` — digests + ancillary | ✅ (full immutables loop pending) |
|
| Genesis Ed25519 verification | ✅ live mainnet + preprod |
|
||||||
| **Genesis Ed25519 verification** | ✅ live mainnet + preprod |
|
| STM BLS12-381 aggregate verification | ✅ live mainnet + preprod |
|
||||||
| **STM BLS12-381 aggregate verification** | ✅ live mainnet + preprod |
|
| Lottery-win threshold (Taylor series, big.Rat) | ✅ |
|
||||||
| **MCP stdio server** | ✅ 7 tools, Claude/Cursor/Zed compatible |
|
| Merkle batch-proof verification (Blake2b-256) | ✅ |
|
||||||
| Full cert-chain verify (genesis → head) | ⏳ next |
|
| AVK chaining + epoch + hash continuity | ✅ |
|
||||||
|
| **Full chain verification (genesis → head)** | ✅ live mainnet + preprod |
|
||||||
|
| Per-immutable SHA manifest verification | ✅ |
|
||||||
|
| MCP stdio server (8 tools) | ✅ |
|
||||||
|
| Full immutables-download loop (8000+ files) | ⏳ |
|
||||||
|
|
||||||
## Usage
|
## Quick start
|
||||||
|
|
||||||
```
|
```bash
|
||||||
mithril-go info -network mainnet
|
go build ./cmd/mithril-go # produces a 9.5 MB single static binary
|
||||||
mithril-go list -network mainnet
|
|
||||||
mithril-go show -network mainnet latest
|
mithril-go info -network mainnet
|
||||||
mithril-go cert -network mainnet head
|
mithril-go list -network mainnet
|
||||||
mithril-go cert -network mainnet -chain head # walk to genesis
|
mithril-go show -network mainnet latest
|
||||||
mithril-go download -network preprod -out ./db latest # digests + ancillary
|
mithril-go cert -network mainnet head
|
||||||
|
mithril-go cert -network mainnet -chain head # walk to genesis
|
||||||
|
|
||||||
|
mithril-go download -network preprod -out ./snap latest
|
||||||
|
mithril-go verify -network preprod manifest ./snap # SHA-check downloaded files
|
||||||
|
|
||||||
|
mithril-go verify -network mainnet genesis # Ed25519
|
||||||
|
mithril-go verify -network mainnet head # STM BLS aggregate
|
||||||
|
mithril-go verify -network mainnet chain # full walk + every cert
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification sprint plan
|
Every query command supports `-json` for structured output.
|
||||||
|
|
||||||
The verification story splits into two layers:
|
## Architecture
|
||||||
|
|
||||||
### 1. Genesis Ed25519 verification
|
```
|
||||||
|
cmd/mithril-go/
|
||||||
|
main.go CLI entrypoint, subcommand dispatch
|
||||||
|
mcp.go MCP tool registration
|
||||||
|
json.go Structured-output helpers
|
||||||
|
|
||||||
The genesis certificate (terminates the chain; its `previous_hash` is
|
internal/
|
||||||
empty and `genesis_signature` is non-empty) is signed by a static
|
aggregator/ REST client (list, get, cert, walk-chain)
|
||||||
Ed25519 key baked into this client per network (`internal/networks`).
|
artifact/ Resumable HTTP download + streamed zstd-tar extract
|
||||||
|
chain/ End-to-end chain verify (genesis → head, AVK chaining)
|
||||||
|
manifest/ Per-immutable SHA-256 verification against digests.json
|
||||||
|
mcp/ Minimal JSON-RPC 2.0 stdio MCP server (no deps)
|
||||||
|
networks/ Per-network aggregator URLs + genesis verify keys
|
||||||
|
stm/ STM BLS12-381 verification: types, BLS, aggregation,
|
||||||
|
lottery, Merkle, top-level Verify
|
||||||
|
verify/ Genesis Ed25519 verification
|
||||||
|
```
|
||||||
|
|
||||||
- Key encoding: the Mithril genesis key is serialized as an ASCII-
|
## What was non-obvious (so future-you doesn't have to dig)
|
||||||
representation of a 32-byte array literal (e.g. `"[191,66,...]"`)
|
|
||||||
then hex-encoded. Decoder needs to unwrap both levels before handing
|
|
||||||
32 raw bytes to `ed25519.Verify`.
|
|
||||||
- Signed payload: `signed_message` field (32 bytes hex) is the output
|
|
||||||
of hashing the serialized `protocol_message` — the exact hash
|
|
||||||
function and canonicalization must match the Rust reference
|
|
||||||
(`mithril-common/src/protocol/` in the upstream repo). Likely
|
|
||||||
Blake2b-256 over a deterministic CBOR or JSON encoding; needs
|
|
||||||
confirming against upstream.
|
|
||||||
- Wire location: `internal/verify/verify.go` → `Genesis(...)`.
|
|
||||||
|
|
||||||
### 2. STM BLS12-381 aggregate verification
|
Three things in the upstream Rust that aren't documented anywhere
|
||||||
|
prominent:
|
||||||
|
|
||||||
Every non-genesis certificate carries a `multi_signature` that is an
|
1. **DST is empty.** Mithril's BLS hash-to-G1 uses an empty domain
|
||||||
STM (Stake-based Threshold Multi-signature) aggregate proof over BLS12-381.
|
separation tag, not the IETF standard `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_`.
|
||||||
|
The Rust calls `blst.verify(sig, msg, &[], &[], pk, ...)` — the
|
||||||
|
`&[]` is the empty DST.
|
||||||
|
2. **The signed message is `signed_message_bytes || mt_root_bytes`**,
|
||||||
|
not `signed_message` alone. The Merkle commitment root is appended
|
||||||
|
before BLS verify.
|
||||||
|
3. **Aggregation is MuSig-style scalar-weighted, not plain.**
|
||||||
|
`t_i = Blake2b-128(Blake2b-128(σ_0‖…‖σ_{n-1}) ‖ be_u64(i))`,
|
||||||
|
then `aggr_sig = Σ t_i·sig_i` and `aggr_vk = Σ t_i·vk_i`.
|
||||||
|
Plain summation does not interop with blst.
|
||||||
|
|
||||||
- Scheme: Chotard/Kiayias/Peters "Stake-based Threshold Multisignatures"
|
Plus the `protocol_message` hash is SHA-256 over key-then-value, with
|
||||||
(Mithril paper §5-6).
|
keys ordered by **Rust enum declaration order**, not alphabetical.
|
||||||
- Library: `github.com/supranational/blst` Go bindings (IETF-draft
|
|
||||||
BLS12-381 operations; production-grade, consensus layers use it).
|
|
||||||
- Inputs:
|
|
||||||
- `next_aggregate_verification_key` from the previous-epoch cert's
|
|
||||||
`protocol_message` (the "trust handoff" between certs)
|
|
||||||
- `multi_signature` bytes (CBOR-encoded STM aggregate signature)
|
|
||||||
- `signed_message` (what was signed)
|
|
||||||
- Output: pass/fail, plus the epoch-boundary decision to promote
|
|
||||||
that cert's `next_aggregate_verification_key` for use by the NEXT
|
|
||||||
verification.
|
|
||||||
- Wire location: `internal/verify/verify.go` → `STM(...)`.
|
|
||||||
|
|
||||||
### Downstream once verification lands
|
|
||||||
|
|
||||||
- `verify` subcommand: takes a snapshot directory, walks the cert chain,
|
|
||||||
verifies genesis Ed25519 + each STM signature in order, validates the
|
|
||||||
`merkle_root` against the digests manifest's computed root, reports
|
|
||||||
per-stage pass/fail.
|
|
||||||
- Per-immutable SHA check against the `digests.json` manifest (already
|
|
||||||
downloaded — 16836 entries for preprod as of epoch 284).
|
|
||||||
- Full immutables loop for the `download -immutables` path.
|
|
||||||
|
|
||||||
## Machine / LLM usage
|
## Machine / LLM usage
|
||||||
|
|
||||||
Every query command accepts `-json` for structured output:
|
Every query command accepts `-json`:
|
||||||
|
|
||||||
```
|
```
|
||||||
mithril-go list -network mainnet -json # snapshot array
|
mithril-go verify -network mainnet -json chain | jq '.steps[] | select(.kind=="genesis")'
|
||||||
mithril-go show -network mainnet latest -json
|
|
||||||
mithril-go cert -network mainnet head -json
|
|
||||||
mithril-go cert -network mainnet head -chain -json
|
|
||||||
mithril-go info -network mainnet -json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit codes are stable:
|
Stable exit codes:
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -120,24 +121,58 @@ Exit codes are stable:
|
||||||
|
|
||||||
These are the contract — existing codes won't renumber.
|
These are the contract — existing codes won't renumber.
|
||||||
|
|
||||||
Planned: `mithril-go mcp` stdio server (Model Context Protocol) so MCP-native
|
### MCP server
|
||||||
agents (Claude Code, Cursor, etc.) can discover + call commands without
|
|
||||||
shelling out. Not yet implemented.
|
`mithril-go mcp` brings up a Model Context Protocol stdio server.
|
||||||
|
Compatible with any MCP client (Claude Code, Cursor, Zed, custom agents).
|
||||||
|
|
||||||
|
Tools exposed:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `mithril_info` | Network + aggregator + genesis verify key |
|
||||||
|
| `mithril_list_snapshots` | Newest-first list of cardano-database snapshots |
|
||||||
|
| `mithril_show_snapshot` | Detail for a snapshot (or `latest`) |
|
||||||
|
| `mithril_get_certificate` | Cert by hash (or `head`) |
|
||||||
|
| `mithril_walk_cert_chain` | Walk previous_hash from head to genesis |
|
||||||
|
| `mithril_verify_certificate` | Ed25519 OR STM BLS verify, dispatched by cert kind |
|
||||||
|
| `mithril_verify_chain` | Full chain verify with per-step report |
|
||||||
|
| `mithril_verify_genesis` | Walk to genesis + Ed25519 verify (legacy single-purpose tool) |
|
||||||
|
|
||||||
|
Example agent flow:
|
||||||
|
```
|
||||||
|
agent: tools/call mithril_verify_chain {network: mainnet}
|
||||||
|
→ {verified: true, length: 89, genesis_hash: "...", steps: [...]}
|
||||||
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `github.com/klauspost/compress/zstd` — pure Go zstd decoder
|
- `github.com/consensys/gnark-crypto` — pure Go BLS12-381 (audited)
|
||||||
- (pending) BLS12-381: `github.com/supranational/blst` via its Go bindings
|
- `golang.org/x/crypto/blake2b` — stdlib-adjacent Blake2b
|
||||||
|
- `github.com/klauspost/compress/zstd` — pure Go zstd
|
||||||
|
|
||||||
|
No CGo. No `blst`. `go build ./cmd/mithril-go` produces a single static
|
||||||
|
binary. Cross-compile with `GOOS=linux GOARCH=arm64 go build`.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go build -o mithril-go ./cmd/mithril-go
|
go build -o mithril-go ./cmd/mithril-go
|
||||||
|
go test ./...
|
||||||
|
go test -tags live ./... # hits live preprod aggregator
|
||||||
```
|
```
|
||||||
|
|
||||||
Produces a single static binary (~9.5 MB). CGo is not used; cross-
|
## Verified against live networks (latest run)
|
||||||
compilation is `GOOS=linux GOARCH=arm64 go build ./cmd/mithril-go`.
|
|
||||||
|
```
|
||||||
|
mainnet chain 89 STM + 1 genesis ✓
|
||||||
|
mainnet head 59 signers, 1972 wins ✓
|
||||||
|
mainnet genesis Ed25519 ✓
|
||||||
|
preprod chain 89 STM + 1 genesis ✓
|
||||||
|
preprod head 2 signers, 11 wins ✓
|
||||||
|
preprod genesis Ed25519 ✓
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TBD
|
Apache-2.0. See `LICENSE`. Matches the upstream Mithril project.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import (
|
||||||
|
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/chain"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/manifest"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
||||||
|
|
@ -93,7 +95,7 @@ Commands:
|
||||||
show Show detail for one snapshot (hash or "latest")
|
show Show detail for one snapshot (hash or "latest")
|
||||||
cert Show a certificate or walk the chain back to genesis
|
cert Show a certificate or walk the chain back to genesis
|
||||||
download Download a snapshot to a target directory
|
download Download a snapshot to a target directory
|
||||||
verify Verify a certificate (genesis Ed25519 working; STM BLS pending)
|
verify Verify certificates (genesis / head / chain / <hash>)
|
||||||
mcp Run as a Model Context Protocol server over stdio
|
mcp Run as a Model Context Protocol server over stdio
|
||||||
info Show network + aggregator info
|
info Show network + aggregator info
|
||||||
version Print version
|
version Print version
|
||||||
|
|
@ -291,12 +293,95 @@ func cmdVerify(ctx context.Context, args []string) int {
|
||||||
return runVerifyGenesis(ctx, c, n, *asJSON)
|
return runVerifyGenesis(ctx, c, n, *asJSON)
|
||||||
case "head":
|
case "head":
|
||||||
return runVerifyHead(ctx, c, n, *asJSON)
|
return runVerifyHead(ctx, c, n, *asJSON)
|
||||||
|
case "chain":
|
||||||
|
return runVerifyChain(ctx, n, *asJSON)
|
||||||
|
case "manifest":
|
||||||
|
return runVerifyManifest(rest[1:], *asJSON)
|
||||||
default:
|
default:
|
||||||
// Treat as a literal cert hash: fetch + verify
|
// Treat as a literal cert hash: fetch + verify
|
||||||
return runVerifySingle(ctx, c, n, mode, *asJSON)
|
return runVerifySingle(ctx, c, n, mode, *asJSON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runVerifyManifest(args []string, asJSON bool) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "verify manifest: needs path to download dir (with digests/ + db/)")
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
dir := args[0]
|
||||||
|
digestsPath, err := manifest.LocateDigests(filepath.Join(dir, "digests"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "locate digests.json:", err)
|
||||||
|
return exitGeneric
|
||||||
|
}
|
||||||
|
entries, err := manifest.Load(digestsPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "load manifest:", err)
|
||||||
|
return exitIntegrity
|
||||||
|
}
|
||||||
|
res, err := manifest.Verify(entries, filepath.Join(dir, "db"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "verify manifest:", err)
|
||||||
|
return exitGeneric
|
||||||
|
}
|
||||||
|
if asJSON {
|
||||||
|
code := emitJSON(res)
|
||||||
|
if !res.OK {
|
||||||
|
return exitIntegrity
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
fmt.Fprintf(os.Stderr, "manifest verify FAILED: verified=%d/%d missing=%d mismatched=%d unverifiable=%d\n",
|
||||||
|
res.Verified, res.TotalEntries, len(res.Missing), len(res.Mismatched), len(res.Unverifiable))
|
||||||
|
return exitIntegrity
|
||||||
|
}
|
||||||
|
fmt.Printf("manifest verify ✓ %d/%d files match (extra: %d)\n",
|
||||||
|
res.Verified, res.TotalEntries, len(res.Extra))
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVerifyChain(ctx context.Context, n networks.Network, asJSON bool) int {
|
||||||
|
c := aggregator.New(n.AggregatorURL)
|
||||||
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "resolve:", err)
|
||||||
|
return exitNetwork
|
||||||
|
}
|
||||||
|
res, err := chain.Verify(ctx, nil, n, snap.CertificateHash, 2048)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "chain verify:", err)
|
||||||
|
return exitNetwork
|
||||||
|
}
|
||||||
|
if asJSON {
|
||||||
|
code := emitJSON(res)
|
||||||
|
if !res.Verified {
|
||||||
|
return exitBadSig
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if !res.Verified {
|
||||||
|
fmt.Fprintf(os.Stderr, "chain verify FAILED at step %d (%s): %s\n",
|
||||||
|
res.FailureIndex, res.FailureKind, res.Error)
|
||||||
|
return exitBadSig
|
||||||
|
}
|
||||||
|
fmt.Printf("chain verify ✓ network=%s length=%d head=%s genesis=%s\n",
|
||||||
|
res.Network, res.Length, res.HeadHash, res.GenesisHash)
|
||||||
|
// Quick summary of STM/genesis breakdown
|
||||||
|
stmCount, genCount, totalWins := 0, 0, 0
|
||||||
|
for _, step := range res.Steps {
|
||||||
|
if step.Kind == "stm" {
|
||||||
|
stmCount++
|
||||||
|
totalWins += step.TotalWins
|
||||||
|
} else {
|
||||||
|
genCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" %d STM certs + %d genesis cert | %d total lottery wins across chain\n",
|
||||||
|
stmCount, genCount, totalWins)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int {
|
func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int {
|
||||||
// Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert.
|
// Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert.
|
||||||
snap, err := resolveSnapshot(ctx, c, "latest")
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
||||||
|
|
@ -552,7 +637,7 @@ func cmdMCP(ctx context.Context, args []string) int {
|
||||||
Version: version,
|
Version: version,
|
||||||
})
|
})
|
||||||
registerMCPTools(s)
|
registerMCPTools(s)
|
||||||
fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 7)
|
fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 8)
|
||||||
if err := s.Run(ctx); err != nil {
|
if err := s.Run(ctx); err != nil {
|
||||||
if err == context.Canceled || err == context.DeadlineExceeded {
|
if err == context.Canceled || err == context.DeadlineExceeded {
|
||||||
return exitCanceled
|
return exitCanceled
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/chain"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
||||||
|
|
@ -290,6 +291,39 @@ func registerMCPTools(s *mcp.Server) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.RegisterTool(mcp.Tool{
|
||||||
|
Name: "mithril_verify_chain",
|
||||||
|
Description: "End-to-end verification: walk from the latest snapshot's head cert back to genesis, verify every cert (Ed25519 or STM BLS as appropriate), and check epoch + hash + AVK continuity at every boundary. Returns a full step-by-step report.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"network": networkEnum,
|
||||||
|
"max_depth": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2048,
|
||||||
|
"description": "Safety cap on the chain walk length",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Handler: func(ctx context.Context, args map[string]any) (any, error) {
|
||||||
|
n, c, err := networkArgOrDefault(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maxDepth := 2048
|
||||||
|
if v, ok := args["max_depth"]; ok {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
maxDepth = int(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return chain.Verify(ctx, nil, n, snap.CertificateHash, maxDepth)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
s.RegisterTool(mcp.Tool{
|
s.RegisterTool(mcp.Tool{
|
||||||
Name: "mithril_verify_genesis",
|
Name: "mithril_verify_genesis",
|
||||||
Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " +
|
Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " +
|
||||||
|
|
|
||||||
8
go.mod
8
go.mod
|
|
@ -2,11 +2,13 @@ module git.sulkta.coop/Sulkta-Coop/mithril-go
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require github.com/klauspost/compress v1.18.5
|
require (
|
||||||
|
github.com/consensys/gnark-crypto v0.20.1
|
||||||
|
github.com/klauspost/compress v1.18.5
|
||||||
|
golang.org/x/crypto v0.50.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/consensys/gnark-crypto v0.20.1 // indirect
|
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
14
go.sum
14
go.sum
|
|
@ -2,11 +2,21 @@ github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoG
|
||||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
|
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
|
||||||
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
|
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||||
|
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
309
internal/chain/chain.go
Normal file
309
internal/chain/chain.go
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
// Package chain implements end-to-end Mithril certificate chain verification.
|
||||||
|
//
|
||||||
|
// Given a head certificate hash and a trusted genesis verification key,
|
||||||
|
// Verify walks backwards through previous_hash until it reaches the
|
||||||
|
// genesis certificate, checking at every step:
|
||||||
|
//
|
||||||
|
// 1. The cert itself is validly signed (Ed25519 for genesis, STM BLS for
|
||||||
|
// standard certs — delegated to internal/verify and internal/stm).
|
||||||
|
// 2. Epoch chaining: the previous cert's epoch is the same as, or exactly
|
||||||
|
// one less than, the current cert's epoch.
|
||||||
|
// 3. Hash chaining: current.previous_hash == previous.hash.
|
||||||
|
// 4. AVK chaining: current.aggregate_verification_key equals either the
|
||||||
|
// previous cert's aggregate_verification_key (same epoch) or the
|
||||||
|
// previous cert's protocol_message.next_aggregate_verification_key
|
||||||
|
// (epoch transition).
|
||||||
|
//
|
||||||
|
// Returns a ChainResult that documents every verified cert and any gaps.
|
||||||
|
package chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
||||||
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step is the per-cert record in a chain-verification report.
|
||||||
|
type Step struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Epoch uint64 `json:"epoch"`
|
||||||
|
Kind string `json:"kind"` // "genesis" | "stm"
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Signers int `json:"signers,omitempty"`
|
||||||
|
TotalWins int `json:"total_wins,omitempty"`
|
||||||
|
DistinctWins int `json:"distinct_wins,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the final chain-verification report.
|
||||||
|
type Result struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
HeadHash string `json:"head_hash"`
|
||||||
|
GenesisHash string `json:"genesis_hash,omitempty"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
FailureIndex int `json:"failure_index,omitempty"`
|
||||||
|
FailureKind string `json:"failure_kind,omitempty"` // "cert" | "epoch" | "hash" | "avk"
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify walks the certificate chain from headHash back to the genesis cert,
|
||||||
|
// verifying each cert and the continuity between adjacent certs.
|
||||||
|
//
|
||||||
|
// maxDepth caps the walk length (default 2048 — enough for years of certs).
|
||||||
|
//
|
||||||
|
// httpClient is used to fetch the raw certificate JSON which carries fields
|
||||||
|
// (aggregate_verification_key, protocol_parameters) that the canonical
|
||||||
|
// aggregator.Certificate type doesn't expose. Pass nil for http.DefaultClient.
|
||||||
|
func Verify(ctx context.Context, httpClient *http.Client, network networks.Network, headHash string, maxDepth int) (*Result, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
if maxDepth <= 0 {
|
||||||
|
maxDepth = 2048
|
||||||
|
}
|
||||||
|
client := aggregator.New(network.AggregatorURL)
|
||||||
|
result := &Result{Network: network.Name, HeadHash: headHash}
|
||||||
|
|
||||||
|
// Walk chain head → genesis, collecting canonical + raw JSON for each.
|
||||||
|
type entry struct {
|
||||||
|
cert *aggregator.Certificate
|
||||||
|
raw *rawCert
|
||||||
|
}
|
||||||
|
var entries []entry
|
||||||
|
next := headHash
|
||||||
|
for len(entries) < maxDepth {
|
||||||
|
cert, err := client.GetCertificate(ctx, next)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("fetch cert %s: %w", next, err)
|
||||||
|
}
|
||||||
|
raw, err := fetchRaw(ctx, httpClient, network.AggregatorURL, next)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("fetch raw cert %s: %w", next, err)
|
||||||
|
}
|
||||||
|
entries = append(entries, entry{cert: cert, raw: raw})
|
||||||
|
if cert.GenesisSignature != "" {
|
||||||
|
result.GenesisHash = cert.Hash
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next = cert.PreviousHash
|
||||||
|
if next == "" {
|
||||||
|
return result, fmt.Errorf("chain broke at depth %d: no previous_hash", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Length = len(entries)
|
||||||
|
if result.GenesisHash == "" {
|
||||||
|
result.Error = fmt.Sprintf("exceeded max depth %d without hitting genesis", maxDepth)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each cert. We walk earliest-first (genesis → head) so AVK
|
||||||
|
// handoff validation is natural.
|
||||||
|
//
|
||||||
|
// Indexes are reversed from collection order: entries[0] = head, but
|
||||||
|
// we want genesis first.
|
||||||
|
vk, err := verify.DecodeGenesisVerifyKey(network.GenesisVerifyKey)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = "decode genesis verify key: " + err.Error()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
result.Steps = make([]Step, len(entries))
|
||||||
|
|
||||||
|
for i := len(entries) - 1; i >= 0; i-- {
|
||||||
|
idx := len(entries) - 1 - i // step index in result.Steps (0 = genesis)
|
||||||
|
e := entries[i]
|
||||||
|
step := Step{Hash: e.cert.Hash, Epoch: e.cert.Epoch}
|
||||||
|
|
||||||
|
if e.cert.GenesisSignature != "" {
|
||||||
|
step.Kind = "genesis"
|
||||||
|
err := verify.GenesisFromJSON(vk, e.cert.SignedMessage, e.cert.GenesisSignature, e.cert.ProtocolMessage)
|
||||||
|
step.Verified = err == nil
|
||||||
|
if err != nil {
|
||||||
|
step.Error = err.Error()
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "cert"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
step.Kind = "stm"
|
||||||
|
ms, err := stm.DecodeMultiSig(e.raw.MultiSignature)
|
||||||
|
if err != nil {
|
||||||
|
step.Error = "decode multi_signature: " + err.Error()
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "cert"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
avk, err := stm.DecodeAVK(e.raw.AggregateVerificationKey)
|
||||||
|
if err != nil {
|
||||||
|
step.Error = "decode avk: " + err.Error()
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "cert"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
params := stm.Parameters{
|
||||||
|
K: e.raw.Metadata.Parameters.K,
|
||||||
|
M: e.raw.Metadata.Parameters.M,
|
||||||
|
PhiF: e.raw.Metadata.Parameters.PhiF,
|
||||||
|
}
|
||||||
|
step.Signers = len(ms.Signatures)
|
||||||
|
step.TotalWins = ms.TotalWins()
|
||||||
|
step.DistinctWins = len(ms.DistinctWins())
|
||||||
|
if err := stm.Verify([]byte(e.cert.SignedMessage), ms, avk, params); err != nil {
|
||||||
|
step.Error = err.Error()
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "cert"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
step.Verified = true
|
||||||
|
|
||||||
|
// Continuity checks: this cert's AVK / previous_hash / epoch vs previous cert.
|
||||||
|
if i+1 < len(entries) {
|
||||||
|
prev := entries[i+1]
|
||||||
|
// previous_hash chaining
|
||||||
|
if e.cert.PreviousHash != prev.cert.Hash {
|
||||||
|
step.Error = fmt.Sprintf("previous_hash mismatch: got %s, want %s",
|
||||||
|
e.cert.PreviousHash, prev.cert.Hash)
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "hash"
|
||||||
|
result.Error = step.Error
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// epoch chaining: allow same epoch or exactly one greater
|
||||||
|
switch {
|
||||||
|
case e.cert.Epoch == prev.cert.Epoch:
|
||||||
|
// same epoch — AVK must match previous.aggregate_verification_key
|
||||||
|
if !bytesEq(e.raw.AggregateVerificationKey, prev.raw.AggregateVerificationKey) {
|
||||||
|
step.Error = "same-epoch AVK mismatch"
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "avk"
|
||||||
|
result.Error = step.Error
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
case e.cert.Epoch == prev.cert.Epoch+1:
|
||||||
|
// new epoch — AVK must match previous.protocol_message.next_aggregate_verification_key
|
||||||
|
prevNextAVK, err := extractNextAVK(prev.cert.ProtocolMessage)
|
||||||
|
if err != nil {
|
||||||
|
step.Error = "extract prev next_avk: " + err.Error()
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "avk"
|
||||||
|
result.Error = step.Error
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if !avkHexMatches(e.raw.AggregateVerificationKey, prevNextAVK) {
|
||||||
|
step.Error = "epoch-transition AVK does not match previous.next_aggregate_verification_key"
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "avk"
|
||||||
|
result.Error = step.Error
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
step.Error = fmt.Sprintf("epoch gap: %d → %d", prev.cert.Epoch, e.cert.Epoch)
|
||||||
|
result.Steps[idx] = step
|
||||||
|
result.FailureIndex = idx
|
||||||
|
result.FailureKind = "epoch"
|
||||||
|
result.Error = step.Error
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Steps[idx] = step
|
||||||
|
}
|
||||||
|
result.Verified = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawCert is the subset of the certificate JSON that chain verification
|
||||||
|
// consumes beyond what aggregator.Certificate exposes.
|
||||||
|
type rawCert struct {
|
||||||
|
MultiSignature json.RawMessage `json:"multi_signature"`
|
||||||
|
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
|
||||||
|
Metadata struct {
|
||||||
|
Parameters struct {
|
||||||
|
K uint64 `json:"k"`
|
||||||
|
M uint64 `json:"m"`
|
||||||
|
PhiF float64 `json:"phi_f"`
|
||||||
|
} `json:"parameters"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRaw(ctx context.Context, client *http.Client, aggURL, hash string) (*rawCert, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, aggURL+"/certificate/"+hash, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("aggregator %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var r rawCert
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesEq(a, b json.RawMessage) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractNextAVK pulls the next_aggregate_verification_key string out of a
|
||||||
|
// protocol_message JSON object.
|
||||||
|
func extractNextAVK(pm json.RawMessage) (string, error) {
|
||||||
|
var v struct {
|
||||||
|
MessageParts map[string]string `json:"message_parts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pm, &v); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
k, ok := v.MessageParts["next_aggregate_verification_key"]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no next_aggregate_verification_key in protocol_message")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// avkHexMatches tests whether a raw JSON-string AVK value equals a given
|
||||||
|
// hex string. Both are hex-of-ASCII-JSON; compare the inner hex strings.
|
||||||
|
func avkHexMatches(rawJSON json.RawMessage, hexStr string) bool {
|
||||||
|
if len(rawJSON) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inner := string(rawJSON)
|
||||||
|
if inner[0] == '"' && inner[len(inner)-1] == '"' {
|
||||||
|
inner = inner[1 : len(inner)-1]
|
||||||
|
}
|
||||||
|
return inner == hexStr
|
||||||
|
}
|
||||||
139
internal/manifest/manifest.go
Normal file
139
internal/manifest/manifest.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// Package manifest verifies downloaded Mithril snapshot files against the
|
||||||
|
// per-immutable digest manifest extracted from the snapshot's `digests`
|
||||||
|
// archive.
|
||||||
|
//
|
||||||
|
// The manifest is a flat JSON array of {immutable_file_name, digest}
|
||||||
|
// entries — one per file in the chain DB's immutable directory. Each
|
||||||
|
// digest is the SHA-256 of the file contents.
|
||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one record from the digests JSON.
|
||||||
|
type Entry struct {
|
||||||
|
ImmutableFileName string `json:"immutable_file_name"`
|
||||||
|
Digest string `json:"digest"` // hex-encoded SHA-256
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads + parses a digests.json file.
|
||||||
|
func Load(path string) ([]Entry, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []Entry
|
||||||
|
if err := json.NewDecoder(f).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocateDigests finds the digests.json file inside the extracted digests
|
||||||
|
// directory (named like `<network>-e<epoch>-i<imm>.digests.json`).
|
||||||
|
func LocateDigests(digestsDir string) (string, error) {
|
||||||
|
entries, err := os.ReadDir(digestsDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".digests.json") {
|
||||||
|
return filepath.Join(digestsDir, e.Name()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no digests.json found under %s", digestsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result captures the per-file outcome of a manifest verification pass.
|
||||||
|
type Result struct {
|
||||||
|
TotalEntries int `json:"total_entries"`
|
||||||
|
Verified int `json:"verified"`
|
||||||
|
Missing []string `json:"missing,omitempty"` // files in manifest, not on disk
|
||||||
|
Mismatched []string `json:"mismatched,omitempty"` // SHA didn't match
|
||||||
|
Unverifiable []string `json:"unverifiable,omitempty"` // failed to read
|
||||||
|
Extra []string `json:"extra,omitempty"` // files on disk, not in manifest
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify walks the manifest and checks each immutable file under dbDir.
|
||||||
|
// Files in the immutable subdirectory are at `<dbDir>/immutable/<name>`.
|
||||||
|
func Verify(entries []Entry, dbDir string) (*Result, error) {
|
||||||
|
r := &Result{TotalEntries: len(entries)}
|
||||||
|
immDir := filepath.Join(dbDir, "immutable")
|
||||||
|
|
||||||
|
// Build set of expected names for the extra-file diff.
|
||||||
|
expected := make(map[string]struct{}, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
expected[e.ImmutableFileName] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
path := filepath.Join(immDir, e.ImmutableFileName)
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
r.Missing = append(r.Missing, e.ImmutableFileName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": is a directory")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, err := sha256OfFile(path)
|
||||||
|
if err != nil {
|
||||||
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != e.Digest {
|
||||||
|
r.Mismatched = append(r.Mismatched, fmt.Sprintf("%s: got %s, want %s",
|
||||||
|
e.ImmutableFileName, got, e.Digest))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Verified++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify any extra files in immutable/ that aren't in the manifest.
|
||||||
|
if entries, err := os.ReadDir(immDir); err == nil {
|
||||||
|
for _, fe := range entries {
|
||||||
|
if fe.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := expected[fe.Name()]; !ok {
|
||||||
|
r.Extra = append(r.Extra, fe.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(r.Missing)
|
||||||
|
sort.Strings(r.Mismatched)
|
||||||
|
sort.Strings(r.Unverifiable)
|
||||||
|
sort.Strings(r.Extra)
|
||||||
|
r.OK = len(r.Missing) == 0 && len(r.Mismatched) == 0 && len(r.Unverifiable) == 0
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256OfFile(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue