diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml
new file mode 100644
index 0000000..10d7847
--- /dev/null
+++ b/.forgejo/workflows/gitleaks.yml
@@ -0,0 +1,40 @@
+# .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 4688b85..952e820 100644
--- a/README.md
+++ b/README.md
@@ -2,46 +2,38 @@
Pure-Go client for the Cardano [Mithril](https://mithril.network) protocol.
-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.
+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.
-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
+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.
## Status
-**Working consensus-correct verification against live mainnet and preprod.**
+Consensus-correct verification against live mainnet and preprod.
| Capability | Status |
|---|---|
-| 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) | ⏳ |
+| 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 |
## Quick start
```bash
-go build ./cmd/mithril-go # produces a 9.5 MB single static binary
+go build ./cmd/mithril-go # ~9.5 MB single static binary
mithril-go info -network mainnet
mithril-go list -network mainnet
@@ -79,27 +71,24 @@ internal/
verify/ Genesis Ed25519 verification
```
-## What was non-obvious (so future-you doesn't have to dig)
-
-Three things in the upstream Rust that aren't documented anywhere
-prominent:
+## Upstream details that aren't documented
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`**,
+ 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`**,
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.
-Plus the `protocol_message` hash is SHA-256 over key-then-value, with
-keys ordered by **Rust enum declaration order**, not alphabetical.
+The `protocol_message` hash is SHA-256 over key-then-value, with keys
+ordered by **Rust enum declaration order**, not alphabetical.
-## Machine / LLM usage
+## JSON + exit codes
Every query command accepts `-json`:
@@ -119,12 +108,11 @@ Stable exit codes:
| 5 | signature verification failure (genesis or STM) |
| 130 | canceled (SIGINT) |
-These are the contract — existing codes won't renumber.
+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:
@@ -135,15 +123,9 @@ 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 (legacy single-purpose tool) |
-
-Example agent flow:
-```
-agent: tools/call mithril_verify_chain {network: mainnet}
- → {verified: true, length: 89, genesis_hash: "...", steps: [...]}
-```
+| `mithril_verify_genesis` | Walk to genesis + Ed25519 verify |
## Dependencies
@@ -165,14 +147,14 @@ go test -tags live ./... # hits live preprod aggregator
## 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 ✓
+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
```
## License
-Apache-2.0. See `LICENSE`. Matches the upstream Mithril project.
+Apache-2.0. See `LICENSE`. Matches upstream Mithril.
diff --git a/cmd/mithril-go/json.go b/cmd/mithril-go/json.go
index b647a47..85a3315 100644
--- a/cmd/mithril-go/json.go
+++ b/cmd/mithril-go/json.go
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"fmt"
- "io"
"os"
)
@@ -19,12 +18,39 @@ func emitJSON(v any) int {
return 0
}
-// 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)
+// 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)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{
- "error": map[string]string{"code": code, "message": msg},
+ "error": map[string]any{
+ "code": code,
+ "kind": kind,
+ "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 b253c61..e4318af 100644
--- a/cmd/mithril-go/main.go
+++ b/cmd/mithril-go/main.go
@@ -16,6 +16,7 @@ import (
"encoding/json"
"flag"
"fmt"
+ "io"
"net/http"
"os"
"os/signal"
@@ -24,17 +25,17 @@ import (
"text/tabwriter"
"time"
- "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"
+ "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"
)
-const version = "0.0.3-dev"
+const version = "1.0.1"
// Stable exit codes. Any addition goes at the end; existing values
// don't renumber. LLM/automation-friendly contract.
@@ -132,14 +133,12 @@ func cmdList(ctx context.Context, args []string) int {
asJSON := fs.Bool("json", false, "emit structured JSON")
n, _, err := resolveNetwork(fs, args)
if err != nil {
- fmt.Fprintln(os.Stderr, err)
- return 2
+ return failure(*asJSON, exitUsage, "usage", "", err)
}
c := aggregator.New(n.AggregatorURL)
snaps, err := c.ListCardanoDBSnapshots(ctx)
if err != nil {
- fmt.Fprintln(os.Stderr, "list:", err)
- return exitNetwork
+ return failure(*asJSON, exitNetwork, "network", "list", err)
}
if *asJSON {
return emitJSON(map[string]any{
@@ -167,8 +166,7 @@ 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 {
- fmt.Fprintln(os.Stderr, err)
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "", err)
}
hash := "latest"
if len(rest) > 0 {
@@ -177,8 +175,7 @@ func cmdShow(ctx context.Context, args []string) int {
c := aggregator.New(n.AggregatorURL)
snap, err := resolveSnapshot(ctx, c, hash)
if err != nil {
- fmt.Fprintln(os.Stderr, "show:", err)
- return exitNetwork
+ return failure(*asJSON, exitNetwork, "network", "show", err)
}
if *asJSON {
return emitJSON(snap)
@@ -282,12 +279,11 @@ 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 {
- fmt.Fprintln(os.Stderr, err)
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "", err)
}
if len(rest) == 0 {
- fmt.Fprintln(os.Stderr, "verify: cert hash required (or 'head' / 'genesis')")
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "",
+ fmt.Errorf("verify: cert hash required (or 'head' / 'genesis' / 'chain' / 'manifest
')"))
}
mode := rest[0] // "head" = verify head cert (STM, not yet), "genesis" = walk chain + verify genesis, or a specific hash
c := aggregator.New(n.AggregatorURL)
@@ -309,24 +305,21 @@ func cmdVerify(ctx context.Context, args []string) int {
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
+ return failure(asJSON, exitUsage, "usage", "",
+ fmt.Errorf("verify manifest: needs path to download dir (with digests/ + db/)"))
}
dir := args[0]
digestsPath, err := manifest.LocateDigests(filepath.Join(dir, "digests"))
if err != nil {
- fmt.Fprintln(os.Stderr, "locate digests.json:", err)
- return exitGeneric
+ return failure(asJSON, exitGeneric, "internal", "locate digests.json", err)
}
entries, err := manifest.Load(digestsPath)
if err != nil {
- fmt.Fprintln(os.Stderr, "load manifest:", err)
- return exitIntegrity
+ return failure(asJSON, exitIntegrity, "integrity", "load manifest", err)
}
res, err := manifest.Verify(entries, filepath.Join(dir, "db"))
if err != nil {
- fmt.Fprintln(os.Stderr, "verify manifest:", err)
- return exitGeneric
+ return failure(asJSON, exitGeneric, "internal", "verify manifest", err)
}
if asJSON {
code := emitJSON(res)
@@ -349,13 +342,11 @@ 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
+ return failure(asJSON, exitNetwork, "network", "resolve", err)
}
res, err := chain.Verify(ctx, nil, n, snap.CertificateHash, 2048)
if err != nil {
- fmt.Fprintln(os.Stderr, "chain verify:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "chain verify", err)
}
if asJSON {
code := emitJSON(res)
@@ -390,22 +381,19 @@ 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 {
- fmt.Fprintln(os.Stderr, "resolve:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "resolve", err)
}
- chain, err := c.CertChain(ctx, snap.CertificateHash, 2048)
+ certs, err := c.CertChain(ctx, snap.CertificateHash, 2048)
if err != nil {
- fmt.Fprintln(os.Stderr, "chain:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "chain", err)
}
- if len(chain) == 0 {
- fmt.Fprintln(os.Stderr, "empty chain")
- return exitGeneric
+ if len(certs) == 0 {
+ return failure(asJSON, exitGeneric, "internal", "", fmt.Errorf("empty chain"))
}
- gen := chain[len(chain)-1]
+ gen := certs[len(certs)-1]
if gen.GenesisSignature == "" {
- fmt.Fprintln(os.Stderr, "tail of chain is not a genesis certificate")
- return exitGeneric
+ return failure(asJSON, exitGeneric, "internal", "",
+ fmt.Errorf("tail of chain is not a genesis certificate"))
}
return verifyGenesisCert(n, gen, asJSON)
}
@@ -413,8 +401,7 @@ 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 {
- fmt.Fprintln(os.Stderr, "resolve:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "resolve", err)
}
return runVerifySingle(ctx, c, n, snap.CertificateHash, asJSON)
}
@@ -422,8 +409,7 @@ 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 {
- fmt.Fprintln(os.Stderr, "cert:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "cert", err)
}
if cert.GenesisSignature != "" {
return verifyGenesisCert(n, cert, asJSON)
@@ -438,18 +424,15 @@ 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 {
- fmt.Fprintln(os.Stderr, "fetch raw cert:", err)
- return exitNetwork
+ return failure(asJSON, exitNetwork, "network", "fetch raw cert", err)
}
ms, err := stm.DecodeMultiSig(raw.MultiSignature)
if err != nil {
- fmt.Fprintln(os.Stderr, "decode multi_signature:", err)
- return exitIntegrity
+ return failure(asJSON, exitIntegrity, "integrity", "decode multi_signature", err)
}
avk, err := stm.DecodeAVK(raw.AggregateVerificationKey)
if err != nil {
- fmt.Fprintln(os.Stderr, "decode avk:", err)
- return exitIntegrity
+ return failure(asJSON, exitIntegrity, "integrity", "decode avk", err)
}
msg := []byte(cert.SignedMessage)
params := stm.Parameters{K: raw.Metadata.Parameters.K, M: raw.Metadata.Parameters.M, PhiF: raw.Metadata.Parameters.PhiF}
@@ -501,14 +484,21 @@ 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(resp.Body).Decode(&r); err != nil {
- return nil, err
+ if err := json.NewDecoder(limited).Decode(&r); err != nil {
+ return nil, fmt.Errorf("decode cert json: %w", err)
}
return &r, nil
}
@@ -516,8 +506,7 @@ 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 {
- fmt.Fprintln(os.Stderr, "decode genesis key:", err)
- return exitGeneric
+ return failure(asJSON, exitGeneric, "internal", "decode genesis key", err)
}
err = verify.GenesisFromJSON(vk, cert.SignedMessage, cert.GenesisSignature, cert.ProtocolMessage)
if asJSON {
@@ -552,28 +541,25 @@ 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 {
- fmt.Fprintln(os.Stderr, err)
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "", err)
}
if len(rest) == 0 {
- fmt.Fprintln(os.Stderr, "cert: hash required (or 'head' to use the latest snapshot's cert_hash)")
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "",
+ fmt.Errorf("cert: hash required (or 'head' to use the latest snapshot's cert_hash)"))
}
head := rest[0]
c := aggregator.New(n.AggregatorURL)
if head == "head" {
snap, err := resolveSnapshot(ctx, c, "latest")
if err != nil {
- fmt.Fprintln(os.Stderr, "resolve head:", err)
- return exitNetwork
+ return failure(*asJSON, exitNetwork, "network", "resolve head", err)
}
head = snap.CertificateHash
}
if *chain {
certs, err := c.CertChain(ctx, head, *maxDepth)
if err != nil {
- fmt.Fprintln(os.Stderr, "chain:", err)
- return exitNetwork
+ return failure(*asJSON, exitNetwork, "network", "chain", err)
}
if *asJSON {
return emitJSON(map[string]any{"chain_length": len(certs), "certs": certs})
@@ -591,8 +577,7 @@ func cmdCert(ctx context.Context, args []string) int {
}
cert, err := c.GetCertificate(ctx, head)
if err != nil {
- fmt.Fprintln(os.Stderr, "cert:", err)
- return exitNetwork
+ return failure(*asJSON, exitNetwork, "network", "cert", err)
}
if *asJSON {
return emitJSON(cert)
@@ -619,8 +604,7 @@ func cmdInfo(args []string) int {
asJSON := fs.Bool("json", false, "emit structured JSON")
n, _, err := resolveNetwork(fs, args)
if err != nil {
- fmt.Fprintln(os.Stderr, err)
- return exitUsage
+ return failure(*asJSON, exitUsage, "usage", "", err)
}
if *asJSON {
return emitJSON(map[string]any{
diff --git a/cmd/mithril-go/mcp.go b/cmd/mithril-go/mcp.go
index 3e8f9a7..684a36b 100644
--- a/cmd/mithril-go/mcp.go
+++ b/cmd/mithril-go/mcp.go
@@ -4,12 +4,12 @@ import (
"context"
"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"
- "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify"
+ "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"
)
func errString(e error) string {
diff --git a/go.mod b/go.mod
index 9344073..f2de901 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module git.sulkta.coop/Sulkta-Coop/mithril-go
+module git.sulkta.com/Sulkta-Coop/mithril-go
go 1.26
diff --git a/internal/artifact/download.go b/internal/artifact/download.go
index e29546a..575d94c 100644
--- a/internal/artifact/download.go
+++ b/internal/artifact/download.go
@@ -121,6 +121,10 @@ 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 0bdb061..7e17b9f 100644
--- a/internal/chain/chain.go
+++ b/internal/chain/chain.go
@@ -24,10 +24,10 @@ import (
"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"
+ "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"
)
// Step is the per-cert record in a chain-verification report.
diff --git a/internal/stm/lottery.go b/internal/stm/lottery.go
index 5c23ebc..6d6bd15 100644
--- a/internal/stm/lottery.go
+++ b/internal/stm/lottery.go
@@ -58,6 +58,12 @@ 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 51c4211..9984997 100644
--- a/internal/stm/merkle.go
+++ b/internal/stm/merkle.go
@@ -4,7 +4,6 @@ import (
"bytes"
"encoding/binary"
"fmt"
- "sort"
"golang.org/x/crypto/blake2b"
)
@@ -78,16 +77,31 @@ 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))
}
- // Must be sorted ascending
+ 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.)
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)
}
- 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)
+ 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)
}
}
@@ -155,6 +169,12 @@ 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 dd86488..c040edb 100644
--- a/internal/stm/types.go
+++ b/internal/stm/types.go
@@ -16,8 +16,7 @@
// 4. Lottery check: for each (index, sigma), evaluate_dense_mapping < threshold(stake)
// 5. Threshold: total distinct lottery wins >= k
//
-// Phases 2-5 are stubbed in verify.go pending the BLS crypto sprint.
-// This package's current role is rock-solid decoding.
+// Phases 2-5 are implemented in verify.go (BLS, lottery, Merkle, threshold).
package stm
import (
@@ -116,6 +115,12 @@ 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 ab02fce..a49cf5d 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 is fully implemented here. STM verification
-// is stubbed pending the BLS crypto sprint.
+// Ed25519 (genesis) verification lives here. STM BLS verification is in
+// the sibling internal/stm package.
package verify
import (
@@ -27,7 +27,6 @@ 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
@@ -182,9 +181,5 @@ func GenesisFromJSON(verifyKey ed25519.PublicKey, signedMessageHex, genesisSigna
return Genesis(verifyKey, signedMessageHex, genesisSignatureHex, pm)
}
-// 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
-}
+// STM verification lives in the sibling internal/stm package — see
+// stm.Verify(). This file is genesis-Ed25519-only.