Skip to content

Commit 1156b1e

Browse files
committed
fix(security): fail closed when the snapshot checksum is unavailable
Whether a snapshot was verified at all was decided by pre.HasMD5Sidecar, a flag set from a plaintext HEAD of the .md5sum URL. Anyone on the path could answer that HEAD with a 404 and the tarball was then extracted with expectedMD5 empty, no error, exit 0 - the attacker-controlled response was effectively a 'skip integrity checking' switch, and the CLI reported '(md5 sidecar absent - not verified)' as a success. The sidecar is now fetched unconditionally whenever the operator has not opted out, and a 404, non-200 or transport failure returns a typed VerificationUnavailableError before any tarball byte is requested, so nothing is written. Driven A/B against a binary built from develop: base extracts attacker state with exit 0 on a 404, a 500 and a transport reset; this refuses all three with an empty destination. The MCP snapshot_download tool had no opt-out at all, so it always extracted unverified on a 404; it gains a no_verify arg alongside the CLI's existing --no-verify, which survives the --detach re-exec. All eight mirrors were re-probed and every one returns 200 with a coreutils-format body, so no in-repo caller needs the opt-out. The docs still say sidecars can be absent - corrected to say the download now refuses rather than extracting. Adds one additive optional field to snapshot-download.schema.json, so SchemaVersion goes 1.12.2 -> 1.12.3 with a regenerated baseline.
1 parent 7745d76 commit 1156b1e

14 files changed

Lines changed: 442 additions & 64 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,13 @@ A pin is only worth the channel you got it over — a digest read off the
310310
same cleartext mirror buys nothing. Surface `plaintext_transport` to the
311311
user rather than deciding for them; if they have an out-of-band digest,
312312
pass it through `--sha256` (or the MCP `snapshot_download` `sha256` arg).
313+
If a download exits non-zero with `VERIFICATION_UNAVAILABLE`, the mirror's
314+
`.md5sum` sidecar could not be fetched, so trond refused to extract a chain
315+
database it cannot check — nothing was written to the destination. Retry,
316+
or pick another backup/mirror. Only pass `--no-verify` (MCP:
317+
`no_verify: true`) when the user has explicitly accepted an unverified
318+
chain database; a completed download reports
319+
`"verification_skipped": true` in that case.
313320

314321
When the download finishes, its manifest + log stay under
315322
`~/.trond/snapshots/<id>.{json,log}` so you can audit later. Stale

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,14 @@ The agent-ergonomics arc lands across four sequenced PRs:
246246
download. Existing MD5 behaviour, verification ordering, and disk
247247
headroom are unchanged. Schema `1.12.2``1.12.3` (additive optional
248248
fields on one schema).
249+
- `snapshot download` fails closed on integrity: the `.md5sum` sidecar is
250+
always fetched (a preflight HEAD no longer decides whether to verify),
251+
and a 404 / transport failure aborts with `VERIFICATION_UNAVAILABLE`
252+
(exit 1) instead of silently extracting an unverified chain database.
253+
`--no-verify` (MCP `snapshot_download` gains `no_verify`, previously
254+
absent) is the only way to skip the check; results carry
255+
`verification_skipped` so `md5_verified: false` can no longer be read
256+
as "the mirror had no sidecar". Schema 1.12.2 → 1.12.3
249257

250258
## [0.1.0-alpha] — 2026-XX-XX
251259

cmd/snapshot/detach_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ func TestStripDetach(t *testing.T) {
6161
in: []string{"trond", "--state-dir", "/tmp", "snapshot", "download", "--detach", "--to", "/data"},
6262
want: []string{"trond", "--state-dir", "/tmp", "snapshot", "download", "--to", "/data"},
6363
},
64+
{
65+
// The detached child is the process that actually fetches the
66+
// sidecar and refuses to extract without it, so the operator's
67+
// deliberate opt-out has to survive the re-exec.
68+
name: "preserves --no-verify so the opt-out reaches the detached child",
69+
in: []string{"trond", "snapshot", "download", "--detach", "--no-verify", "--network", "nile"},
70+
want: []string{"trond", "snapshot", "download", "--no-verify", "--network", "nile"},
71+
},
6472
}
6573

6674
for _, c := range cases {

cmd/snapshot/download.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ var downloadCmd = &cobra.Command{
3636
Short: "Stream a snapshot tarball into a local directory",
3737
Long: `Download a chain database snapshot, streaming the tarball through
3838
gunzip + tar so the .tgz is never persisted to disk. Verifies the
39-
upstream MD5 sidecar (when published), pre-checks free disk space, and
40-
refuses to overwrite an existing database without --force.
39+
upstream MD5 sidecar — if the sidecar cannot be fetched the download
40+
aborts rather than extracting unchecked data, unless you pass
41+
--no-verify. Also pre-checks free disk space and refuses to overwrite an
42+
existing database without --force.
4143
4244
The default destination is ./output-directory under the current working
4345
directory — same convention as the official tron-docker tooling. Pass
@@ -77,7 +79,7 @@ func init() {
7779
downloadCmd.Flags().StringVar(&dlDest, "to", "", "Destination directory (default ./output-directory)")
7880
downloadCmd.Flags().StringVar(&dlNode, "node", "", "Managed node name; resolves --to from state")
7981
downloadCmd.Flags().BoolVar(&dlForce, "force", false, "Overwrite existing database in destination")
80-
downloadCmd.Flags().BoolVar(&dlNoVerify, "no-verify", false, "Skip MD5 verification (not recommended)")
82+
downloadCmd.Flags().BoolVar(&dlNoVerify, "no-verify", false, "Extract without checking the MD5 sidecar (UNSAFE; otherwise a missing sidecar aborts the download)")
8183
downloadCmd.Flags().StringVar(&dlSHA256, "sha256", "",
8284
"Expected SHA-256 of the tarball, obtained out of band (64 hex chars). "+
8385
"Unlike the upstream .md5sum — which travels the same channel as the "+
@@ -181,6 +183,15 @@ func runDownload(cmd *cobra.Command, _ []string) error {
181183
if errors.As(err, &ow) {
182184
return output.NewError("HUMAN_REQUIRED", output.ExitHumanRequired, ow.Error())
183185
}
186+
var vu *snapshot.VerificationUnavailableError
187+
if errors.As(err, &vu) {
188+
return output.NewError("VERIFICATION_UNAVAILABLE", output.ExitGeneralError, vu.Error()).
189+
WithSuggestions(
190+
"Retry — the sidecar may be published a few minutes after the tarball, or the mirror may be briefly unhealthy",
191+
fmt.Sprintf("Pick another backup: trond snapshot list --network %s", src.Network),
192+
"Only if you accept an unauthenticated chain database: re-run with --no-verify",
193+
)
194+
}
184195
return output.NewError("DOWNLOAD_ERROR", output.ExitGeneralError, err.Error())
185196
}
186197

@@ -193,8 +204,11 @@ func runDownload(cmd *cobra.Command, _ []string) error {
193204
humanGB(uint64(res.BytesDownloaded)), res.Duration.Round(time.Second), res.FilesExtracted, dest)
194205
if res.MD5Verified {
195206
fmt.Fprint(cmd.OutOrStdout(), " (md5 ✓)")
196-
} else if !dlNoVerify {
197-
fmt.Fprint(cmd.OutOrStdout(), " (md5 sidecar absent — not verified)")
207+
} else {
208+
// Only reachable with --no-verify: without it, a sidecar that is
209+
// missing or unfetchable aborts the download rather than landing
210+
// an unchecked chain database here.
211+
fmt.Fprint(cmd.OutOrStdout(), " (NOT VERIFIED — --no-verify was passed; this chain database is unauthenticated)")
198212
}
199213
if res.SHA256Verified {
200214
fmt.Fprint(cmd.OutOrStdout(), " (sha256 pin ✓)")
@@ -225,11 +239,14 @@ func downloadPayload(src *snapshot.Source, backup, dest string, res *snapshot.Do
225239
"bytes_downloaded": res.BytesDownloaded,
226240
"duration_ms": res.DurationMs,
227241
"md5_verified": res.MD5Verified,
228-
"actual_md5": res.ActualMD5,
229-
"files_extracted": res.FilesExtracted,
230-
"userdata_present": pre.UserdataPresent,
231-
"sha256": res.SHA256,
232-
"sha256_verified": res.SHA256Verified,
242+
// Distinguishes "checked and good" from "deliberately unchecked":
243+
// a missing sidecar is now an error, never a silent skip.
244+
"verification_skipped": res.VerificationSkipped,
245+
"actual_md5": res.ActualMD5,
246+
"files_extracted": res.FilesExtracted,
247+
"userdata_present": pre.UserdataPresent,
248+
"sha256": res.SHA256,
249+
"sha256_verified": res.SHA256Verified,
233250
// The cleartext-transport fact has to reach agents that read
234251
// stdout only and never see the stderr warning.
235252
"plaintext_transport": res.PlaintextTransport,
@@ -271,6 +288,10 @@ func emitPlan(outputFmt string, src *snapshot.Source, backup, dest string, pre *
271288
if pre.PlaintextTransport {
272289
fmt.Print(snapshot.PlaintextWarning(pre.URL, dlSHA256 != ""))
273290
}
291+
if !pre.HasMD5Sidecar {
292+
fmt.Println(" WARNING: the sidecar did not answer this probe — the download will")
293+
fmt.Println(" refuse to extract unverified data unless you pass --no-verify.")
294+
}
274295
if pre.WouldOverwrite {
275296
fmt.Println(" WARNING: existing database would be overwritten (use --force).")
276297
}

internal/knowledge/files/snapshots.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ Before any tarball bytes hit the wire, trond:
7070

7171
1. Sends an HTTP HEAD to the tarball URL → reads `Content-Length`. The
7272
body is never opened on this probe.
73-
2. Issues a separate HEAD to the `.md5sum` sidecar → records whether
74-
inline verification will be possible.
73+
2. Issues a separate HEAD to the `.md5sum` sidecar → records whether the
74+
mirror advertises one. Informational only: it never decides whether
75+
verification happens (see "MD5 verification").
7576
3. `Statfs(destination)` → reads available bytes (Bavail × Bsize, the
7677
same number `df` shows).
7778
4. Refuses to start the GET if free space < `Content-Length × 2`.
@@ -118,10 +119,12 @@ If a mirror ever gains HTTPS, switch its `BaseURL` in
118119

119120
## What the MD5 sidecar does and does not buy
120121

121-
Mainnet mirrors publish `<tarball>.tgz.md5sum` sidecars. trond:
122+
Mainnet and Nile mirrors both publish `<tarball>.tgz.md5sum` sidecars.
123+
trond:
122124

123-
1. HEADs the sidecar in preflight (records "has md5 sidecar: true/false").
124-
2. Downloads the sidecar (a few hundred bytes).
125+
1. HEADs the sidecar in preflight (records "has md5 sidecar: true/false"
126+
for `--dry-run`; this answer decides nothing).
127+
2. Downloads the sidecar (a few hundred bytes) before the tarball GET.
125128
3. Hashes the tarball stream while extracting.
126129
4. Compares — mismatch fails the operation with the database in whatever
127130
partial state extraction reached.
@@ -134,10 +137,21 @@ substitute the sidecar, and the two will agree. MD5 is also collision-prone
134137
on its own merits. So the sidecar check proves the transfer was not
135138
*corrupted*; it proves nothing about where the bytes came from.
136139

137-
Nile and the occasional outage may leave the sidecar absent. trond will
138-
still extract; the result message reads `(md5 sidecar absent — not
139-
verified)`. Pass `--no-verify` to suppress that note when you've made
140-
the choice deliberately.
140+
A mirror outage — or a mirror that stops publishing sidecars — can still
141+
leave the sidecar absent. **trond then refuses to download**: step 2
142+
fails with `VERIFICATION_UNAVAILABLE` (exit 1) and nothing is written to
143+
the destination. The same applies to any transport failure fetching the
144+
sidecar. The preflight HEAD from step 1 is not consulted: it arrives over
145+
the same unauthenticated HTTP channel as the tarball, so a 404 there is
146+
never taken as permission to skip the check.
147+
148+
If you accept an unverified chain database, say so explicitly with
149+
`--no-verify` (MCP: `no_verify: true`); the flag carries through
150+
`--detach` to the background child. The result line then reads
151+
`(NOT VERIFIED — --no-verify was passed; this chain database is
152+
unauthenticated)` and JSON output carries `"verification_skipped": true`.
153+
Treat such a database as untrusted input — it is the state your node will
154+
serve to every dApp, explorer and exchange that queries it.
141155

142156
## `--sha256`: the check that can detect substitution
143157

@@ -301,6 +315,14 @@ version change.
301315
- Malformed pin, rejected before the transfer starts. SHA-256 digests are
302316
64 hex characters; you may have pasted an MD5 (32).
303317

318+
`VERIFICATION_UNAVAILABLE: cannot verify snapshot integrity: <url> is
319+
unavailable ...`
320+
- The `.md5sum` sidecar 404'd or the fetch failed, so trond has nothing
321+
to check the tarball against and refuses to extract. Retry (sidecars
322+
are sometimes published a few minutes after the tarball), pick another
323+
backup with `trond snapshot list`, or switch `--region` / `--domain`.
324+
`--no-verify` bypasses the check and should be a deliberate choice.
325+
304326
## Local database backup with `db_cp`
305327

306328
`scripts/db_cp.sh` makes a fast, space-efficient local copy of a

internal/mcp/tools_snapshot.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ type snapshotDownloadArgs struct {
3232
Force bool `json:"force,omitempty" jsonschema:"overwrite an existing chain DB (DESTRUCTIVE)"`
3333
DryRun bool `json:"dry_run,omitempty" jsonschema:"print the plan and exit without downloading"`
3434
SHA256 string `json:"sha256,omitempty" jsonschema:"expected SHA-256 of the tarball (64 hex chars) obtained out of band; a mismatch fails the download. The mainnet mirrors are cleartext HTTP and their .md5sum sidecar rides the same channel, so this pin is the only check that can detect a substituted archive"`
35+
// NoVerify is the deliberate opt-out from integrity checking. Without
36+
// it, a sidecar that cannot be fetched aborts the download instead of
37+
// silently extracting unverified chain data — mirroring `--no-verify`
38+
// on the CLI so an agent isn't stuck when a mirror genuinely stops
39+
// publishing sidecars.
40+
NoVerify bool `json:"no_verify,omitempty" jsonschema:"UNSAFE: extract without checking the MD5 sidecar; otherwise a missing/unfetchable sidecar aborts the download"`
3541
}
3642

3743
func registerSnapshotTools(s *mcp.Server) {
@@ -59,7 +65,7 @@ func registerSnapshotTools(s *mcp.Server) {
5965
mcp.AddTool(s, &mcp.Tool{
6066
Name: "snapshot_download",
6167
Title: "Download a chain DB snapshot",
62-
Description: `Stream a snapshot tarball into a destination directory, gunzip + tar in one pipeline (no .tgz on disk). Pre-checks free disk space (HEAD probe + Statfs, requires 2× headroom). Refuses overwrite of an existing chain DB unless force=true. Preserves any pre-existing userdata/. MD5-verifies inline against the published sidecar when present.
68+
Description: `Stream a snapshot tarball into a destination directory, gunzip + tar in one pipeline (no .tgz on disk). Pre-checks free disk space (HEAD probe + Statfs, requires 2× headroom). Refuses overwrite of an existing chain DB unless force=true. Preserves any pre-existing userdata/. MD5-verifies inline against the published sidecar; if the sidecar cannot be fetched the download fails instead of extracting unverified chain data — pass no_verify=true only to accept that risk deliberately.
6369
6470
Use dry_run=true to inspect the plan first. The tool emits MCP progress notifications during the actual download so the client can render a live progress bar. NOTE: this MCP tool runs the download in-process and blocks until completion or context cancellation; for fire-and-forget mainnet-full sized downloads (multi-hour) prefer the CLI with --detach.
6571
@@ -147,6 +153,7 @@ func snapshotDownloadTool(ctx context.Context, req *mcp.CallToolRequest, args sn
147153
DestDir: args.Dest,
148154
Force: args.Force,
149155
ExpectedSHA256: args.SHA256,
156+
NoVerify: args.NoVerify,
150157
}
151158

152159
pre, err := snapshot.Preflight(ctx, opts)
@@ -188,11 +195,14 @@ func snapshotDownloadTool(ctx context.Context, req *mcp.CallToolRequest, args sn
188195
"bytes_downloaded": res.BytesDownloaded,
189196
"duration_ms": res.DurationMs,
190197
"md5_verified": res.MD5Verified,
191-
"actual_md5": res.ActualMD5,
192-
"files_extracted": res.FilesExtracted,
193-
"userdata_present": pre.UserdataPresent,
194-
"sha256": res.SHA256,
195-
"sha256_verified": res.SHA256Verified,
198+
// A missing sidecar is an error now, so md5_verified=false means
199+
// exactly one thing: the caller passed no_verify.
200+
"verification_skipped": res.VerificationSkipped,
201+
"actual_md5": res.ActualMD5,
202+
"files_extracted": res.FilesExtracted,
203+
"userdata_present": pre.UserdataPresent,
204+
"sha256": res.SHA256,
205+
"sha256_verified": res.SHA256Verified,
196206
// An MCP caller has no stderr to read the warning from, so the
197207
// cleartext-transport fact has to travel in the payload.
198208
"plaintext_transport": res.PlaintextTransport,

internal/schema/embed.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ import (
113113
// inferring it, and pin an out-of-band digest via `--sha256` /
114114
// the MCP `sha256` arg. One existing schema, additive optional
115115
// fields only — PATCH.
116-
const SchemaVersion = "1.12.4"
116+
// 1.12.5 — snapshot-download output gains `verification_skipped`. A
117+
// missing or unfetchable `.md5sum` sidecar is now a hard error
118+
// (`VERIFICATION_UNAVAILABLE`) rather than a silent skip, so
119+
// `md5_verified: false` on a successful download means one
120+
// thing only: the operator passed `--no-verify` / `no_verify`.
121+
// One existing schema, one additive optional field — PATCH.
122+
const SchemaVersion = "1.12.5"
117123

118124
// JSONSchemaURLBase is the canonical URL prefix for individual output
119125
// schema files. Embedded $id values inside each schema mirror this so

internal/schema/files/snapshot-download.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"actual_md5": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
2525
"expected_md5": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
2626
"md5_verified": { "type": "boolean", "description": "The upstream .md5sum sidecar matched. On a plaintext_transport mirror the sidecar arrives over the same unauthenticated channel as the tarball, so this attests transfer integrity only — not provenance." },
27+
"verification_skipped": {
28+
"type": "boolean",
29+
"description": "true only when --no-verify / no_verify was passed. A missing or unfetchable .md5sum sidecar is an error (VERIFICATION_UNAVAILABLE), never a silent skip, so md5_verified=false always means the operator opted out."
30+
},
2731
"dest": { "type": "string" },
2832
"userdata_present": { "type": "boolean" },
2933
"source": { "type": "object" },

internal/schema/version_baseline.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schema_version": "1.12.4",
2+
"schema_version": "1.12.5",
33
"entries": [
44
{
55
"name": "apply",
@@ -103,7 +103,7 @@
103103
},
104104
{
105105
"name": "snapshot-download",
106-
"hash": "24767d4d39d7201e46c9f0d23e575283a768bc5867cab81cb9255ce0f5eb3d66"
106+
"hash": "14517cc14171dcf3f615fa2cf69be553bceab3b83fcea2ac7f913260e64e5c2c"
107107
},
108108
{
109109
"name": "snapshot-jobs",

0 commit comments

Comments
 (0)