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:
Kayos 2026-04-23 16:15:47 -07:00
parent 920d7cf177
commit 599085eaa9
8 changed files with 905 additions and 90 deletions

201
LICENSE Normal file
View 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.

191
README.md
View file

@ -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
```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 ./db latest # digests + ancillary
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.

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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
View 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
}

View 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
}