From 599085eaa920ce6de477cadc509468135689e813 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 23 Apr 2026 16:15:47 -0700 Subject: [PATCH] wrap: chain verify + manifest verify + LICENSE + final docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ' 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. --- LICENSE | 201 ++++++++++++++++++++++ README.md | 201 +++++++++++++--------- cmd/mithril-go/main.go | 89 +++++++++- cmd/mithril-go/mcp.go | 34 ++++ go.mod | 8 +- go.sum | 14 +- internal/chain/chain.go | 309 ++++++++++++++++++++++++++++++++++ internal/manifest/manifest.go | 139 +++++++++++++++ 8 files changed, 905 insertions(+), 90 deletions(-) create mode 100644 LICENSE create mode 100644 internal/chain/chain.go create mode 100644 internal/manifest/manifest.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34c43df --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 2864a82..4688b85 100644 --- a/README.md +++ b/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. diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index 9956123..58c578e 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -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 / ) 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 diff --git a/cmd/mithril-go/mcp.go b/cmd/mithril-go/mcp.go index cdeb995..93862cf 100644 --- a/cmd/mithril-go/mcp.go +++ b/cmd/mithril-go/mcp.go @@ -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 " + diff --git a/go.mod b/go.mod index 7c50ed4..9344073 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index cfc752a..9066f1c 100644 --- a/go.sum +++ b/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= diff --git a/internal/chain/chain.go b/internal/chain/chain.go new file mode 100644 index 0000000..0bdb061 --- /dev/null +++ b/internal/chain/chain.go @@ -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 +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..ef3c772 --- /dev/null +++ b/internal/manifest/manifest.go @@ -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 `-e-i.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 `/immutable/`. +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 +}