diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml deleted file mode 100644 index 10d7847..0000000 --- a/.forgejo/workflows/gitleaks.yml +++ /dev/null @@ -1,40 +0,0 @@ -# .forgejo/workflows/gitleaks.yml -# -# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at -# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered -# (task #295). -# -# Pairs with the pre-receive hook installed on every bare repo — that one is -# the strict enforcement layer (rejects the push); this one provides the -# per-PR red ✗ that branch-protection rules can require before merge. -# -# Layer 1 (this workflow): visible per-PR status, can be a required check. -# Layer 2 (pre-receive hook): strict enforcement at the server. -# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos. - -name: gitleaks - -on: - push: - pull_request: - -jobs: - scan: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history — gitleaks needs depth to scan a commit range. - fetch-depth: 0 - - - name: install gitleaks - run: | - curl -sSL -o gl.tar.gz \ - https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz - tar xzf gl.tar.gz gitleaks - chmod +x gitleaks - ./gitleaks version - - - name: scan - run: | - ./gitleaks detect --source . --no-banner --redact --verbose diff --git a/README.md b/README.md index 952e820..4688b85 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,46 @@ Pure-Go client for the Cardano [Mithril](https://mithril.network) protocol. -Mithril is Cardano's stake-based certified-snapshot system. A new node -bootstraps the chain from a cryptographically-verified snapshot instead -of replaying every block from genesis. +Mithril is Cardano's stake-based certified-snapshot system — it lets a new +node bootstrap the chain from a cryptographically-verified snapshot +instead of replaying every block from genesis. -The reference [`mithril-client`](https://github.com/input-output-hk/mithril) -is Rust. `mithril-go` is a pure-Go reimplementation: single static binary, -no CGo, no `blst`, no upstream Rust runtime. Also ships an MCP stdio -server for agent use. +The official [`mithril-client`](https://github.com/input-output-hk/mithril) +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 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 -Consensus-correct verification against live mainnet and preprod. +**Working consensus-correct verification against live mainnet and preprod.** | Capability | Status | |---|---| -| Aggregator REST client (list, show, cert, walk-chain) | done | -| Resumable HTTP download (SHA hook + progress) | done | -| Streamed zstd+tar extract (tar-slip defended) | done | -| Genesis Ed25519 verification | done | -| STM BLS12-381 aggregate verification | done | -| Lottery-win threshold (Taylor series, big.Rat) | done | -| Merkle batch-proof verification (Blake2b-256) | done | -| AVK chaining + epoch + hash continuity | done | -| Full chain verification (genesis → head) | done | -| Per-immutable SHA manifest verification | done | -| MCP stdio server (8 tools) | done | -| Full immutables-download loop (8000+ files) | pending | +| Aggregator REST client | ✅ list, show, cert, walk-chain | +| Resumable HTTP download (SHA hook + progress) | ✅ | +| Streamed zstd+tar extract (tar-slip defended) | ✅ | +| 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) | ⏳ | ## Quick start ```bash -go build ./cmd/mithril-go # ~9.5 MB single static binary +go build ./cmd/mithril-go # produces a 9.5 MB single static binary mithril-go info -network mainnet mithril-go list -network mainnet @@ -71,24 +79,27 @@ internal/ verify/ Genesis Ed25519 verification ``` -## Upstream details that aren't documented +## What was non-obvious (so future-you doesn't have to dig) + +Three things in the upstream Rust that aren't documented anywhere +prominent: 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. **Signed message is `signed_message_bytes || mt_root_bytes`**, + 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. +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. -The `protocol_message` hash is SHA-256 over key-then-value, with keys -ordered by **Rust enum declaration order**, not alphabetical. +Plus the `protocol_message` hash is SHA-256 over key-then-value, with +keys ordered by **Rust enum declaration order**, not alphabetical. -## JSON + exit codes +## Machine / LLM usage Every query command accepts `-json`: @@ -108,11 +119,12 @@ Stable exit codes: | 5 | signature verification failure (genesis or STM) | | 130 | canceled (SIGINT) | -Existing codes won't renumber. +These are the contract — existing codes won't renumber. -## MCP server +### 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: @@ -123,9 +135,15 @@ Tools exposed: | `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_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 | +| `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 @@ -147,14 +165,14 @@ go test -tags live ./... # hits live preprod aggregator ## Verified against live networks (latest run) ``` -mainnet chain 89 STM + 1 genesis ok -mainnet head 59 signers, 1972 wins ok -mainnet genesis Ed25519 ok -preprod chain 89 STM + 1 genesis ok -preprod head 2 signers, 11 wins ok -preprod genesis Ed25519 ok +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 -Apache-2.0. See `LICENSE`. Matches upstream Mithril. +Apache-2.0. See `LICENSE`. Matches the upstream Mithril project. diff --git a/cmd/mithril-go/json.go b/cmd/mithril-go/json.go index 85a3315..b647a47 100644 --- a/cmd/mithril-go/json.go +++ b/cmd/mithril-go/json.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "io" "os" ) @@ -18,39 +19,12 @@ func emitJSON(v any) int { return 0 } -// emitJSONErr writes a structured error envelope to stdout in the shape -// MCP / agent consumers expect: -// -// {"error": {"code": "...", "message": "..."}} -// -// Returns the supplied exit code so callers can do `return emitJSONErr(...)`. -func emitJSONErr(code int, kind, msg string) int { - enc := json.NewEncoder(os.Stdout) +// emitJSONErr writes a structured error envelope. Mirrors the shape +// Claude/MCP-friendly consumers want: {"error": {"code":..., "message":...}}. +func emitJSONErr(w io.Writer, code, msg string) { + enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(map[string]any{ - "error": map[string]any{ - "code": code, - "kind": kind, - "message": msg, - }, + "error": map[string]string{"code": code, "message": msg}, }) - return code -} - -// failure routes an error to either stdout-as-JSON (when the user passed -// -json) or stderr-as-text (default). Returns the supplied exit code. -// -// kind is a stable short string ("network", "integrity", "verify", -// "usage", "internal") — agents can branch on this without parsing -// human-readable text. -func failure(asJSON bool, code int, kind, prefix string, err error) int { - msg := err.Error() - if prefix != "" { - msg = prefix + ": " + msg - } - if asJSON { - return emitJSONErr(code, kind, msg) - } - fmt.Fprintln(os.Stderr, msg) - return code } diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index e4318af..b253c61 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -16,7 +16,6 @@ import ( "encoding/json" "flag" "fmt" - "io" "net/http" "os" "os/signal" @@ -25,17 +24,17 @@ import ( "text/tabwriter" "time" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/aggregator" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/artifact" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/chain" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/manifest" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/mcp" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/networks" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/stm" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/verify" + "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" + "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify" ) -const version = "1.0.1" +const version = "0.0.3-dev" // Stable exit codes. Any addition goes at the end; existing values // don't renumber. LLM/automation-friendly contract. @@ -133,12 +132,14 @@ func cmdList(ctx context.Context, args []string) int { asJSON := fs.Bool("json", false, "emit structured JSON") n, _, err := resolveNetwork(fs, args) if err != nil { - return failure(*asJSON, exitUsage, "usage", "", err) + fmt.Fprintln(os.Stderr, err) + return 2 } c := aggregator.New(n.AggregatorURL) snaps, err := c.ListCardanoDBSnapshots(ctx) if err != nil { - return failure(*asJSON, exitNetwork, "network", "list", err) + fmt.Fprintln(os.Stderr, "list:", err) + return exitNetwork } if *asJSON { return emitJSON(map[string]any{ @@ -166,7 +167,8 @@ func cmdShow(ctx context.Context, args []string) int { asJSON := fs.Bool("json", false, "emit structured JSON") n, rest, err := resolveNetwork(fs, args) if err != nil { - return failure(*asJSON, exitUsage, "usage", "", err) + fmt.Fprintln(os.Stderr, err) + return exitUsage } hash := "latest" if len(rest) > 0 { @@ -175,7 +177,8 @@ func cmdShow(ctx context.Context, args []string) int { c := aggregator.New(n.AggregatorURL) snap, err := resolveSnapshot(ctx, c, hash) if err != nil { - return failure(*asJSON, exitNetwork, "network", "show", err) + fmt.Fprintln(os.Stderr, "show:", err) + return exitNetwork } if *asJSON { return emitJSON(snap) @@ -279,11 +282,12 @@ func cmdVerify(ctx context.Context, args []string) int { asJSON := fs.Bool("json", false, "emit structured JSON") n, rest, err := resolveNetwork(fs, args) if err != nil { - return failure(*asJSON, exitUsage, "usage", "", err) + fmt.Fprintln(os.Stderr, err) + return exitUsage } if len(rest) == 0 { - return failure(*asJSON, exitUsage, "usage", "", - fmt.Errorf("verify: cert hash required (or 'head' / 'genesis' / 'chain' / 'manifest ')")) + fmt.Fprintln(os.Stderr, "verify: cert hash required (or 'head' / 'genesis')") + return exitUsage } mode := rest[0] // "head" = verify head cert (STM, not yet), "genesis" = walk chain + verify genesis, or a specific hash c := aggregator.New(n.AggregatorURL) @@ -305,21 +309,24 @@ func cmdVerify(ctx context.Context, args []string) int { func runVerifyManifest(args []string, asJSON bool) int { if len(args) == 0 { - return failure(asJSON, exitUsage, "usage", "", - fmt.Errorf("verify manifest: needs path to download dir (with digests/ + db/)")) + 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 { - return failure(asJSON, exitGeneric, "internal", "locate digests.json", err) + fmt.Fprintln(os.Stderr, "locate digests.json:", err) + return exitGeneric } entries, err := manifest.Load(digestsPath) if err != nil { - return failure(asJSON, exitIntegrity, "integrity", "load manifest", err) + fmt.Fprintln(os.Stderr, "load manifest:", err) + return exitIntegrity } res, err := manifest.Verify(entries, filepath.Join(dir, "db")) if err != nil { - return failure(asJSON, exitGeneric, "internal", "verify manifest", err) + fmt.Fprintln(os.Stderr, "verify manifest:", err) + return exitGeneric } if asJSON { code := emitJSON(res) @@ -342,11 +349,13 @@ 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 { - return failure(asJSON, exitNetwork, "network", "resolve", err) + fmt.Fprintln(os.Stderr, "resolve:", err) + return exitNetwork } res, err := chain.Verify(ctx, nil, n, snap.CertificateHash, 2048) if err != nil { - return failure(asJSON, exitNetwork, "network", "chain verify", err) + fmt.Fprintln(os.Stderr, "chain verify:", err) + return exitNetwork } if asJSON { code := emitJSON(res) @@ -381,19 +390,22 @@ func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Netw // Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert. snap, err := resolveSnapshot(ctx, c, "latest") if err != nil { - return failure(asJSON, exitNetwork, "network", "resolve", err) + fmt.Fprintln(os.Stderr, "resolve:", err) + return exitNetwork } - certs, err := c.CertChain(ctx, snap.CertificateHash, 2048) + chain, err := c.CertChain(ctx, snap.CertificateHash, 2048) if err != nil { - return failure(asJSON, exitNetwork, "network", "chain", err) + fmt.Fprintln(os.Stderr, "chain:", err) + return exitNetwork } - if len(certs) == 0 { - return failure(asJSON, exitGeneric, "internal", "", fmt.Errorf("empty chain")) + if len(chain) == 0 { + fmt.Fprintln(os.Stderr, "empty chain") + return exitGeneric } - gen := certs[len(certs)-1] + gen := chain[len(chain)-1] if gen.GenesisSignature == "" { - return failure(asJSON, exitGeneric, "internal", "", - fmt.Errorf("tail of chain is not a genesis certificate")) + fmt.Fprintln(os.Stderr, "tail of chain is not a genesis certificate") + return exitGeneric } return verifyGenesisCert(n, gen, asJSON) } @@ -401,7 +413,8 @@ func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Netw func runVerifyHead(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int { snap, err := resolveSnapshot(ctx, c, "latest") if err != nil { - return failure(asJSON, exitNetwork, "network", "resolve", err) + fmt.Fprintln(os.Stderr, "resolve:", err) + return exitNetwork } return runVerifySingle(ctx, c, n, snap.CertificateHash, asJSON) } @@ -409,7 +422,8 @@ func runVerifyHead(ctx context.Context, c *aggregator.Client, n networks.Network func runVerifySingle(ctx context.Context, c *aggregator.Client, n networks.Network, hash string, asJSON bool) int { cert, err := c.GetCertificate(ctx, hash) if err != nil { - return failure(asJSON, exitNetwork, "network", "cert", err) + fmt.Fprintln(os.Stderr, "cert:", err) + return exitNetwork } if cert.GenesisSignature != "" { return verifyGenesisCert(n, cert, asJSON) @@ -424,15 +438,18 @@ func verifySTMCert(ctx context.Context, c *aggregator.Client, n networks.Network // Re-fetch as raw JSON to access the AVK + params fields. raw, err := fetchCertRaw(ctx, n.AggregatorURL, hash) if err != nil { - return failure(asJSON, exitNetwork, "network", "fetch raw cert", err) + fmt.Fprintln(os.Stderr, "fetch raw cert:", err) + return exitNetwork } ms, err := stm.DecodeMultiSig(raw.MultiSignature) if err != nil { - return failure(asJSON, exitIntegrity, "integrity", "decode multi_signature", err) + fmt.Fprintln(os.Stderr, "decode multi_signature:", err) + return exitIntegrity } avk, err := stm.DecodeAVK(raw.AggregateVerificationKey) if err != nil { - return failure(asJSON, exitIntegrity, "integrity", "decode avk", err) + fmt.Fprintln(os.Stderr, "decode avk:", err) + return exitIntegrity } msg := []byte(cert.SignedMessage) params := stm.Parameters{K: raw.Metadata.Parameters.K, M: raw.Metadata.Parameters.M, PhiF: raw.Metadata.Parameters.PhiF} @@ -484,21 +501,14 @@ func fetchCertRaw(ctx context.Context, aggregatorURL, hash string) (*rawCert, er if err != nil { return nil, err } - req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.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 GET /certificate/%s: %d: %s", hash, resp.StatusCode, string(body)) - } - // Cap at 16 MiB — current mainnet cert JSON is well under 100 KiB. - limited := io.LimitReader(resp.Body, 16<<20) var r rawCert - if err := json.NewDecoder(limited).Decode(&r); err != nil { - return nil, fmt.Errorf("decode cert json: %w", err) + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err } return &r, nil } @@ -506,7 +516,8 @@ func fetchCertRaw(ctx context.Context, aggregatorURL, hash string) (*rawCert, er func verifyGenesisCert(n networks.Network, cert *aggregator.Certificate, asJSON bool) int { vk, err := verify.DecodeGenesisVerifyKey(n.GenesisVerifyKey) if err != nil { - return failure(asJSON, exitGeneric, "internal", "decode genesis key", err) + fmt.Fprintln(os.Stderr, "decode genesis key:", err) + return exitGeneric } err = verify.GenesisFromJSON(vk, cert.SignedMessage, cert.GenesisSignature, cert.ProtocolMessage) if asJSON { @@ -541,25 +552,28 @@ func cmdCert(ctx context.Context, args []string) int { asJSON := fs.Bool("json", false, "emit structured JSON") n, rest, err := resolveNetwork(fs, args) if err != nil { - return failure(*asJSON, exitUsage, "usage", "", err) + fmt.Fprintln(os.Stderr, err) + return exitUsage } if len(rest) == 0 { - return failure(*asJSON, exitUsage, "usage", "", - fmt.Errorf("cert: hash required (or 'head' to use the latest snapshot's cert_hash)")) + fmt.Fprintln(os.Stderr, "cert: hash required (or 'head' to use the latest snapshot's cert_hash)") + return exitUsage } head := rest[0] c := aggregator.New(n.AggregatorURL) if head == "head" { snap, err := resolveSnapshot(ctx, c, "latest") if err != nil { - return failure(*asJSON, exitNetwork, "network", "resolve head", err) + fmt.Fprintln(os.Stderr, "resolve head:", err) + return exitNetwork } head = snap.CertificateHash } if *chain { certs, err := c.CertChain(ctx, head, *maxDepth) if err != nil { - return failure(*asJSON, exitNetwork, "network", "chain", err) + fmt.Fprintln(os.Stderr, "chain:", err) + return exitNetwork } if *asJSON { return emitJSON(map[string]any{"chain_length": len(certs), "certs": certs}) @@ -577,7 +591,8 @@ func cmdCert(ctx context.Context, args []string) int { } cert, err := c.GetCertificate(ctx, head) if err != nil { - return failure(*asJSON, exitNetwork, "network", "cert", err) + fmt.Fprintln(os.Stderr, "cert:", err) + return exitNetwork } if *asJSON { return emitJSON(cert) @@ -604,7 +619,8 @@ func cmdInfo(args []string) int { asJSON := fs.Bool("json", false, "emit structured JSON") n, _, err := resolveNetwork(fs, args) if err != nil { - return failure(*asJSON, exitUsage, "usage", "", err) + fmt.Fprintln(os.Stderr, err) + return exitUsage } if *asJSON { return emitJSON(map[string]any{ diff --git a/cmd/mithril-go/mcp.go b/cmd/mithril-go/mcp.go index 684a36b..3e8f9a7 100644 --- a/cmd/mithril-go/mcp.go +++ b/cmd/mithril-go/mcp.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/aggregator" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/chain" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/mcp" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/networks" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/stm" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/verify" + "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" + "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify" ) func errString(e error) string { diff --git a/go.mod b/go.mod index f2de901..9344073 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.sulkta.com/Sulkta-Coop/mithril-go +module git.sulkta.coop/Sulkta-Coop/mithril-go go 1.26 diff --git a/internal/artifact/download.go b/internal/artifact/download.go index 575d94c..e29546a 100644 --- a/internal/artifact/download.go +++ b/internal/artifact/download.go @@ -121,10 +121,6 @@ func Download(ctx context.Context, uri, destPath, expectedSHA256 string, progres if expectedSHA256 != "" { got := hex.EncodeToString(h.Sum(nil)) if got != expectedSHA256 { - // Remove the .part file — leaving it behind would cause every - // subsequent retry to resume from the same corrupted bytes and - // fail SHA again indefinitely. - _ = os.Remove(partPath) return fmt.Errorf("SHA256 mismatch: want %s, got %s", expectedSHA256, got) } } diff --git a/internal/chain/chain.go b/internal/chain/chain.go index 7e17b9f..0bdb061 100644 --- a/internal/chain/chain.go +++ b/internal/chain/chain.go @@ -24,10 +24,10 @@ import ( "io" "net/http" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/aggregator" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/networks" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/stm" - "git.sulkta.com/Sulkta-Coop/mithril-go/internal/verify" + "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. diff --git a/internal/stm/lottery.go b/internal/stm/lottery.go index 6d6bd15..5c23ebc 100644 --- a/internal/stm/lottery.go +++ b/internal/stm/lottery.go @@ -58,12 +58,6 @@ func IsLotteryWon(phiF float64, ev [64]byte, stake, totalStake uint64) bool { if math.Abs(phiF-1.0) < 1e-15 { return true } - // Defensive: zero-stake or zero-total-stake produces nonsense (and - // totalStake==0 would panic at SetFrac). Guard at the lottery layer - // in addition to AVK-decode-time validation. - if stake == 0 || totalStake == 0 { - return false - } // ev as big int (LE interpretation) evInt := evAsBigInt(ev) diff --git a/internal/stm/merkle.go b/internal/stm/merkle.go index 9984997..51c4211 100644 --- a/internal/stm/merkle.go +++ b/internal/stm/merkle.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "fmt" + "sort" "golang.org/x/crypto/blake2b" ) @@ -77,31 +78,16 @@ func VerifyMerkleBatch(root []byte, nrLeaves int, leafValues [][]byte, indices [ if len(leafValues) != len(indices) { return fmt.Errorf("leaves/indices count mismatch: %d vs %d", len(leafValues), len(indices)) } - if nrLeaves <= 0 { - return fmt.Errorf("nrLeaves must be positive, got %d", nrLeaves) - } - // Validate every proof node is a 32-byte BLAKE2b-256 digest. Anything - // shorter or longer is malformed and Rust would reject it. - for i, v := range proofValues { - if len(v) != 32 { - return fmt.Errorf("proof value [%d]: got %d bytes, want 32", i, len(v)) - } - } - // Indices must be strictly ascending — duplicates would create - // double-claiming under the same leaf and the algorithm doesn't expect - // them. (Rust uses sort_unstable + equality compare against the input; - // equivalent to "non-decreasing" but doesn't reject equal-adjacent. - // We're stricter than upstream here on purpose.) + // Must be sorted ascending ordered := make([]int, len(indices)) for i, v := range indices { - if v >= uint64(nrLeaves) { - return fmt.Errorf("index [%d]=%d out of range (nr_leaves=%d)", i, v, nrLeaves) - } ordered[i] = int(v) } - for i := 1; i < len(ordered); i++ { - if ordered[i] <= ordered[i-1] { - return fmt.Errorf("indices not strictly ascending at [%d]: %v", i, indices) + sortedCopy := append([]int(nil), ordered...) + sort.Ints(sortedCopy) + for i := range ordered { + if ordered[i] != sortedCopy[i] { + return fmt.Errorf("indices not sorted ascending: %v", indices) } } @@ -169,12 +155,6 @@ func VerifyMerkleBatch(root []byte, nrLeaves int, leafValues [][]byte, indices [ if len(currentLayer) != 1 { return fmt.Errorf("verification ended with %d nodes, want 1", len(currentLayer)) } - // All proof values must be consumed. Trailing bytes mean the proof - // shipped extra nodes the algorithm didn't need — likely malformed - // or attacker-padded. - if len(values) > 0 { - return fmt.Errorf("proof has %d unconsumed values — malformed", len(values)) - } if !bytes.Equal(currentLayer[0], root) { return fmt.Errorf("root mismatch: got %x, want %x", currentLayer[0], root) } diff --git a/internal/stm/types.go b/internal/stm/types.go index c040edb..dd86488 100644 --- a/internal/stm/types.go +++ b/internal/stm/types.go @@ -16,7 +16,8 @@ // 4. Lottery check: for each (index, sigma), evaluate_dense_mapping < threshold(stake) // 5. Threshold: total distinct lottery wins >= k // -// Phases 2-5 are implemented in verify.go (BLS, lottery, Merkle, threshold). +// Phases 2-5 are stubbed in verify.go pending the BLS crypto sprint. +// This package's current role is rock-solid decoding. package stm import ( @@ -115,12 +116,6 @@ func DecodeAVK(rawJSON []byte) (*AVK, error) { if len(wire.MTCommitment.Root) != 32 { return nil, fmt.Errorf("AVK root: got %d bytes, want 32", len(wire.MTCommitment.Root)) } - if wire.TotalStake == 0 { - return nil, fmt.Errorf("AVK total_stake is zero") - } - if wire.MTCommitment.NrLeaves == 0 { - return nil, fmt.Errorf("AVK nr_leaves is zero") - } return &AVK{ MerkleRoot: wire.MTCommitment.Root, NumLeaves: wire.MTCommitment.NrLeaves, diff --git a/internal/verify/verify.go b/internal/verify/verify.go index a49cf5d..ab02fce 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -7,8 +7,8 @@ // 2. Subsequent certificates carry an STM (Stake-based Threshold Multi- // signature) aggregate signature over BLS12-381. // -// Ed25519 (genesis) verification lives here. STM BLS verification is in -// the sibling internal/stm package. +// Ed25519 (genesis) verification is fully implemented here. STM verification +// is stubbed pending the BLS crypto sprint. package verify import ( @@ -27,6 +27,7 @@ var ( ErrNotGenesis = errors.New("certificate is not a genesis certificate") ErrBadSignature = errors.New("genesis signature verification failed") ErrSignedMessageHash = errors.New("signed_message does not match SHA256(protocol_message)") + ErrSTMNotImplemented = errors.New("STM signature verification not implemented yet") ) // The Mithril enum order on ProtocolMessagePartKey — BTreeMap iteration @@ -181,5 +182,9 @@ func GenesisFromJSON(verifyKey ed25519.PublicKey, signedMessageHex, genesisSigna return Genesis(verifyKey, signedMessageHex, genesisSignatureHex, pm) } -// STM verification lives in the sibling internal/stm package — see -// stm.Verify(). This file is genesis-Ed25519-only. +// STM verifies a non-genesis certificate's aggregate BLS signature. +// Stub — target is Mithril STM paper §5 (signing) + §6 (aggregation) +// using gnark-crypto's bls12-381 primitives. +func STM(protocolMessageJSON, multiSignature []byte, avk any) error { + return ErrSTMNotImplemented +}