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.
|
||||
|
||||
The official [`mithril-client`](https://github.com/input-output-hk/mithril)
|
||||
is Rust. This project is a pure-Go reimplementation that produces a single
|
||||
static binary with no runtime dependencies — useful for:
|
||||
is Rust. `mithril-go` is a pure-Go reimplementation that produces a single
|
||||
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
|
||||
(alongside `gouroboros`, `dingo`, and friends)
|
||||
- Running on constrained ARM/embedded targets where shipping the Rust
|
||||
binary + its deps is overkill
|
||||
- Embedding Mithril bootstrap into Go-based Cardano tooling (alongside
|
||||
`gouroboros`, `dingo`, etc.)
|
||||
- Constrained ARM/embedded targets where shipping the Rust binary + its
|
||||
deps is overkill
|
||||
- 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
|
||||
|
||||
**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 |
|
||||
| `list` / `show` / `info` / `cert` commands | ✅ mainnet + preprod |
|
||||
| Resumable HTTP download (single stream, SHA hook) | ✅ |
|
||||
| Aggregator REST client | ✅ list, show, cert, walk-chain |
|
||||
| Resumable HTTP download (SHA hook + progress) | ✅ |
|
||||
| Streamed zstd+tar extract (tar-slip defended) | ✅ |
|
||||
| `download` — digests + ancillary | ✅ (full immutables loop pending) |
|
||||
| **Genesis Ed25519 verification** | ✅ live mainnet + preprod |
|
||||
| **STM BLS12-381 aggregate verification** | ✅ live mainnet + preprod |
|
||||
| **MCP stdio server** | ✅ 7 tools, Claude/Cursor/Zed compatible |
|
||||
| Full cert-chain verify (genesis → head) | ⏳ next |
|
||||
| Genesis Ed25519 verification | ✅ live mainnet + preprod |
|
||||
| STM BLS12-381 aggregate verification | ✅ live mainnet + preprod |
|
||||
| Lottery-win threshold (Taylor series, big.Rat) | ✅ |
|
||||
| Merkle batch-proof verification (Blake2b-256) | ✅ |
|
||||
| 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
|
||||
|
||||
```
|
||||
mithril-go info -network mainnet
|
||||
mithril-go list -network mainnet
|
||||
mithril-go show -network mainnet latest
|
||||
mithril-go cert -network mainnet head
|
||||
mithril-go cert -network mainnet -chain head # walk to genesis
|
||||
mithril-go download -network preprod -out ./db latest # digests + ancillary
|
||||
```bash
|
||||
go build ./cmd/mithril-go # produces a 9.5 MB single static binary
|
||||
|
||||
mithril-go info -network mainnet
|
||||
mithril-go list -network mainnet
|
||||
mithril-go show -network mainnet latest
|
||||
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
|
||||
empty and `genesis_signature` is non-empty) is signed by a static
|
||||
Ed25519 key baked into this client per network (`internal/networks`).
|
||||
internal/
|
||||
aggregator/ REST client (list, get, cert, walk-chain)
|
||||
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-
|
||||
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(...)`.
|
||||
## What was non-obvious (so future-you doesn't have to dig)
|
||||
|
||||
### 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
|
||||
STM (Stake-based Threshold Multi-signature) aggregate proof over BLS12-381.
|
||||
1. **DST is empty.** Mithril's BLS hash-to-G1 uses an empty domain
|
||||
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"
|
||||
(Mithril paper §5-6).
|
||||
- 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.
|
||||
Plus the `protocol_message` hash is SHA-256 over key-then-value, with
|
||||
keys ordered by **Rust enum declaration order**, not alphabetical.
|
||||
|
||||
## 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 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
|
||||
mithril-go verify -network mainnet -json chain | jq '.steps[] | select(.kind=="genesis")'
|
||||
```
|
||||
|
||||
Exit codes are stable:
|
||||
Stable exit codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
|
|
@ -120,24 +121,58 @@ Exit codes are stable:
|
|||
|
||||
These are the contract — existing codes won't renumber.
|
||||
|
||||
Planned: `mithril-go mcp` stdio server (Model Context Protocol) so MCP-native
|
||||
agents (Claude Code, Cursor, etc.) can discover + call commands without
|
||||
shelling out. Not yet implemented.
|
||||
### MCP server
|
||||
|
||||
`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
|
||||
|
||||
- `github.com/klauspost/compress/zstd` — pure Go zstd decoder
|
||||
- (pending) BLS12-381: `github.com/supranational/blst` via its Go bindings
|
||||
- `github.com/consensys/gnark-crypto` — pure Go BLS12-381 (audited)
|
||||
- `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
|
||||
|
||||
```
|
||||
```bash
|
||||
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-
|
||||
compilation is `GOOS=linux GOARCH=arm64 go build ./cmd/mithril-go`.
|
||||
## Verified against live networks (latest run)
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
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/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/networks"
|
||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
||||
|
|
@ -93,7 +95,7 @@ Commands:
|
|||
show Show detail for one snapshot (hash or "latest")
|
||||
cert Show a certificate or walk the chain back to genesis
|
||||
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
|
||||
info Show network + aggregator info
|
||||
version Print version
|
||||
|
|
@ -291,12 +293,95 @@ func cmdVerify(ctx context.Context, args []string) int {
|
|||
return runVerifyGenesis(ctx, c, n, *asJSON)
|
||||
case "head":
|
||||
return runVerifyHead(ctx, c, n, *asJSON)
|
||||
case "chain":
|
||||
return runVerifyChain(ctx, n, *asJSON)
|
||||
case "manifest":
|
||||
return runVerifyManifest(rest[1:], *asJSON)
|
||||
default:
|
||||
// Treat as a literal cert hash: fetch + verify
|
||||
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 {
|
||||
// Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert.
|
||||
snap, err := resolveSnapshot(ctx, c, "latest")
|
||||
|
|
@ -552,7 +637,7 @@ func cmdMCP(ctx context.Context, args []string) int {
|
|||
Version: version,
|
||||
})
|
||||
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 == context.Canceled || err == context.DeadlineExceeded {
|
||||
return exitCanceled
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"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/networks"
|
||||
"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{
|
||||
Name: "mithril_verify_genesis",
|
||||
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
|
||||
|
||||
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 (
|
||||
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
|
||||
)
|
||||
|
|
|
|||
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/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/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/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/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/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