Security release/candidate 1 - #4109
Conversation
|
Important Review skippedToo many files! This PR contains 343 files, which is 243 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (343)
You can disable this status message by setting the ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
30539d8 to
ee628a3
Compare
- Remove stale comment in KeepRandomBeaconOperator that incorrectly described the gas cap as a protection against compromised service contracts; the require now enforces success instead - Add missing enabled-path test for clientinfo.Initialize - Add breaking-change callout to docs warning operators that the metrics server is now opt-in (port 0 by default)
… default
Closes residual references to port 9601 as a default in operator-facing
samples and docs so operators copying the canonical configs do not silently
re-enable the now-disabled-by-default metrics/diagnostics HTTP server.
- configs/config.toml.SAMPLE: comment the [clientInfo] section and
Port = 9601 with an inline note that the section is opt-in.
- docs/resources/client-start-help: regenerate the help capture to
"Disabled by default." matching the new flag default in cmd/flags.go.
- docs/resources/docker-start-{mainnet,testnet}-sample: drop the
-p 9601:9601 host-port mapping that re-exposed the diagnostics port.
- docs-v1/run-random-beacon.adoc, bundle-guide.adoc: replace literal
localhost:9601 example with a configurable-port placeholder.
Also resolves the docs/run-keep-node.adoc contradiction with the new
trusted-network guidance: the Ports section instructed operators to
expose the Diagnostics Port publicly for Rewards Allocation, which
conflicts with the Client Info section's "trusted network only"
requirement. Rewritten to require a trusted network path and added a
cross-reference to the Client Info section.
Adds infrastructure/kube/keep-dev/eth-tx-rpc-ws-networkpolicy.yaml
restricting ingress to the dev geth StatefulSet (8545/8546) to
in-namespace pods, defense-in-depth against future cross-namespace
exposure given the StatefulSet binds 0.0.0.0 with --rpcvhosts=* and
--wsorigins=*.
…10) * fix(deps): remediate Sysdig v2.5.2 image vulnerabilities Addresses the CVE findings from the Sysdig scan of thresholdnetwork/keep-client:v2.5.2 by bumping the Go toolchain, golang.org/x/crypto, golang.org/x/net, go.opentelemetry.io/otel, github.com/quic-go/quic-go, github.com/quic-go/webtransport-go, and github.com/pion/interceptor; pulling go-libp2p forward to a quic-go-v0.59-compatible release; and dropping the long-archived protobuf/dev replace, go-addr-util import, and go-ipfs-config test dependency now that equivalent APIs exist upstream. Deferred to follow-up PRs: - github.com/ethereum/go-ethereum v1.13.15 -> v1.17.x (major API churn) - btcd v0.22.3 / v0.23.4 replace removal (legacy compatibility) - Alpine base image (3.21 -> 3.22) for residual OS-package CVEs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: grant pull-requests:read so dorny/paths-filter works on tlabs-xyz The org-level default GITHUB_TOKEN scope on tlabs-xyz/keep-core-security does not include pull-requests access, so dorny/paths-filter@v2 fails with "Resource not accessible by integration" before any downstream job can run. Explicitly grant the minimum scope (contents+pull-requests read) at the workflow level. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docker): set GOTOOLCHAIN=auto so go.mod toolchain directive resolves The golang:1.25-alpine3.21 / golang:1.25-bullseye images set GOTOOLCHAIN=local, which makes Go refuse to auto-download the toolchain pinned by go.mod's `toolchain go1.25.10` directive. Combined with the `go 1.25.7` go-directive normalized by recent go mod tidy runs, the Docker build was failing with: go: go.mod requires go >= 1.25.7 (running go 1.25.5; GOTOOLCHAIN=local) Setting GOTOOLCHAIN=auto in both build stages lets Go honor the toolchain directive and download go1.25.10 when needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docker): golang:1.25 dropped bullseye, switch to bookworm The golang:1.25-bullseye tag does not exist on Docker Hub. Go 1.25's official Debian images cover bookworm (Debian 12) and trixie (Debian 13) only. Bullseye (Debian 11) was retired before 1.25. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docker): pin Go base image patch versions for reproducible builds - build-sources: golang:1.25-alpine3.21 -> golang:1.25.5-alpine3.21 (latest patch available for alpine3.21; GOTOOLCHAIN=auto still fetches 1.25.10 to satisfy go.mod toolchain directive) - build-bins: golang:1.25-bookworm -> golang:1.25.10-bookworm (exact match to go.mod toolchain; no toolchain fetch needed) Locks base-layer reproducibility against floating-tag drift. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Piotr Rosłaniec <p.roslaniec@gmail.com>
…hold-network#15) Addresses the OS-level CVEs flagged in the Sysdig scan of thresholdnetwork/keep-client:v2.5.2 that don't go away with the existing `apk update && apk upgrade`: - CVE-2026-31789 (Critical 9.8) libcrypto3 / libssl3 3.3.6-r0 -> 3.3.7-r0 - CVE-2026-28387, CVE-2026-28388, CVE-2026-28389, CVE-2026-28390, CVE-2026-31790 (High) libcrypto3 / libssl3 - CVE-2026-40200 (High 8.1) musl / musl-utils 1.2.5-r9 -> 1.2.5-r11 - CVE-2026-22184 (High 7.8) zlib 1.3.1-r2 -> 1.3.2-r0 - CVE-2026-27171 (Medium) zlib Stays within the Alpine 3.x ABI family (musl 1.2.x, OpenSSL 3.x). Build and runtime stages both move to 3.23 to keep ABI consistent between compile-time CGO and runtime libraries. build-sources moves to golang:1.25.10-alpine3.23 (matches go.mod toolchain; alpine3.23 carries Go 1.25.10 natively). Co-authored-by: maclane <maclane@nucypher.com>
* fix(deps): bump go-ethereum v1.13.15 -> v1.17.3 Addresses the 5 High-severity CVEs in the v1.13.x line of github.com/ethereum/go-ethereum flagged by the Sysdig scan of thresholdnetwork/keep-client:v2.5.2: - CVE-2026-22862 (High 7.5) -> v1.16.8 - CVE-2026-22868 (High 7.5) -> v1.16.8 - CVE-2026-26313 (High 7.5) -> v1.17.0 - CVE-2026-26314 (High 7.5) -> v1.16.9 - CVE-2026-26315 (High 7.5) -> v1.16.9 v1.17.3 is the latest patch in the v1.17 series. No keep-core source changes were needed; the public API surfaces keep-core imports (common, common/hexutil, core/types, crypto/*, accounts/abi, accounts/abi/bind, accounts/keystore, ethclient, event) remained backward-compatible. Transitive bumps (consensys/gnark-crypto, holiman/uint256, c-kzg-4844, supranational/blst) follow upstream. Verified with go build ./..., go vet ./... (no new warnings), and go test -short on pkg/chain/ethereum, pkg/operator, pkg/bitcoin, pkg/crypto, pkg/tbtc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deps): bump keep-common to v1.7.1-tlabs.1 for go-ethereum v1.16+ compat go-ethereum v1.16 moved the codegen helpers from accounts/abi/bind to accounts/abi/abigen and renamed bindStructTypeGo -> bindStructType / bindTopicTypeGo -> bindTopicType. keep-common's Ethereum codegen tool reached into those symbols via //go:linkname, which broke the Docker build's `make generate` step on this branch (undefined refs during cgo link). threshold-network/keep-common v1.7.1-tlabs.1 (companion fix branch: threshold-network/keep-common#fix/go-ethereum-1.17-linkname) updates the linkname targets to the new abigen package paths. Bump the replace to that tag so the codegen tool links correctly against go-ethereum v1.17.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(client): free disk space on runner before multi-arch build * ci(client): pin free-disk-space action to commit SHA The free-disk-space step runs in the same job that later authenticates to Docker Hub, AWS, and GHCR. @main is a floating ref against a third-party action with shell access to the runner — pin to the immutable v1.3.1 commit SHA to close the supply-chain window. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Piotr Rosłaniec <p.roslaniec@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…cluster)
A set of code paths take an output/input index from one transaction's
outpoint and use it to index a SEPARATELY-fetched transaction's
Outputs/Inputs slice with no bounds check. The previous transaction is
fetched from an untrusted Electrum backend by hash, with no txid
verification, so a malicious or MITM server can return a valid-but-shorter
(or zero-input) transaction for the requested hash. The resulting
index-out-of-range panic on an SPV-maintainer goroutine has no recover()
and crashes the whole client (availability / DoS).
Defense in depth, three layers:
1. Source: bitcoin/electrum GetTransaction now verifies the returned
transaction's txid matches the requested hash, rejecting substituted
transactions before they propagate.
2. Sites: new Transaction.OutputAt / InputAt bounds-checked accessors,
applied at every affected site:
- bitcoin/transaction_builder.go getScript
- maintainer/spv/spv.go isInputCurrentWalletsMainUTXO
- maintainer/spv/deposit_sweep.go
- maintainer/spv/moving_funds.go
- maintainer/spv/moved_funds_sweep.go
- tbtc/wallet.go EnsureWalletSyncedBetweenChains (zero-input case)
3. Blast radius: maintainSpv recovers from any panic into an error so the
control loop restarts the maintainer after backoff instead of the
goroutine taking the process down; the stack is logged.
Tests: Transaction.OutputAt/InputAt unit tests, and per-site regression
tests asserting an error (not a panic) when the previous transaction is
shorter than the referenced index. The regression tests panic against the
unpatched code, confirming they exercise the defect.
Findings: F-002, F-003, F-004, F-006, F-007, F-012.
…ares ComputeGroupPublicKeyShares runs in an unrecovered goroutine. In the reconstructed-share branch it called ScalarBaseMult on shares.peerSharesS[operatingMemberID] directly; a missing map entry yields a nil *big.Int and ScalarBaseMult(nil) panics, crashing the whole beacon node. This is a DEFENSIVE guard, not a confirmed-reachable bug: the DKG disqualification invariants (a member that did not validly reveal its shares is evicted before this phase) are expected to make the branch unreachable. Triage assessed it 2-1 unreachable. The guard checks for the missing/nil share, logs loudly at Error level (so a real protocol-invariant violation is surfaced, not silently masked), and skips the term instead of nil-dereferencing. It does NOT attempt to fabricate a correct share. Test: a regression test drives the reconstructed-share branch with a revealed-shares map that is missing the operating member's entry, and asserts the goroutine completes without panicking (it does not assert the resulting share is correct). The test panics with a nil pointer dereference against the unpatched code. Finding: F-008 (needs-manual-test; retained as defensive hardening).
…t field PastRedemptionRequestedEvents built tbtc.RedemptionRequestedEvent with TxMaxFee taken from event.TreasuryFee (a copy-paste defect). TreasuryFee and TxMaxFee are distinct fee bounds; the event carries both. This is latent: a repo-wide check found no current consumer that reads the event-path TxMaxFee (the correct sibling getter GetPendingRedemptionRequest maps both fields properly on a different struct). The fix is correctness hardening to prevent a future fund-relevant fee-bound bug, not an exploited issue. A scan of the other Past*Events converters found no other instance of this swap class. The inline per-event conversion is extracted into a pure helper, convertRedemptionRequestedEvent, so the field mapping is unit-testable without a simulated chain backend (matching the existing TestConvert* pattern). Behavior is otherwise unchanged, including the error path. Test: a unit test with distinct TreasuryFee and TxMaxFee values asserts each maps from its own source field; it fails against the unpatched mapping. Finding: F-014 (needs-manual-test; latent correctness fix).
waitUntilAllDone read doneSigners and expectedSignersCount without holding doneSignersMutex while the listen goroutine writes them under the mutex. Wrap the ticker.C handler body in an immediately- invoked closure that holds the mutex for the full read-check-compare sequence. Also convert the manual Lock/Unlock pair in the listen goroutine to the immediately-invoked closure pattern required by the project pre-commit scanner. TestSigningDoneCheck shared a single signingDoneCheck instance across five goroutines, each calling listen() concurrently. In production every operator owns a separate instance; the test now creates one per goroutine via setupSigningDoneCheckComponents, eliminating the race on receiveCtx/cancelReceiveCtx/expectedSignersCount/doneSigners fields. (cherry picked from commit 2e71de3)
…dNumberOfTimes The failure message read retransmissionsCount non-atomically while in-flight retransmit goroutines could still increment it, and the assertion itself could observe 9 when the 10th retransmission goroutine was merely late rather than missing (each retransmission runs in a goroutine spawned by the tick handler, which ctx.Done() does not join). Load the counter atomically into a local used for both the comparison and the message, and wait with a bounded deadline for the expected count before asserting.
…ticker tests The race detector (go test -race -count=50) flags every ticker test; confirmed pre-existing on the merge base and present upstream. Two distinct races of the same class as F-023: - Ticker.start's shutdown cleanup deleted from t.handlers without holding handlersMutex after the ticks channel closed, racing with onTick registrations and with any reader. Clear the map under the mutex. - The tests incremented plain int counters from the ticker goroutine and read them (and len(ticker.handlers)) from the test goroutine after fixed sleeps, which establish no happens-before edge. Use atomic counters and bounded polling (waitForCounter, waitForHandlersUnregistered) instead of sleeps; assertions are unchanged. Verified: all ticker tests fail under -race before this change and pass 50 consecutive -race runs after it. The ticker.go hunk diverges from upstream threshold-network/keep-core, which carries the same latent race; candidate for upstreaming.
Integration branch for the Tier 0 + Tier 1 testing/correctness work. Feature PRs are stacked onto this branch and reviewed/merged one by one; this branch then merges to main as a single epic. Stack (bottom -> top): 1. threshold-network#29 ci: -race job + ruleguard accessor lint (Tier 0) 2. threshold-network#30 test: native fuzz targets (Tier 1 / 1a) 3. threshold-network#31 ci: ClusterFuzzLite continuous fuzzing (Tier 1 / 1b) 4. threshold-network#32 test: rapid property tests (Tier 1 / 1c)
Adds a client-race-test job that runs the unit suite under the Go race detector (gotestsum -- -race) in the existing build image. Runs nightly and on manual dispatch only; it is intentionally not a required PR check until proven stable, since the first runs on a codebase that has never had -race enabled are expected to surface latent races and timing flakes. The default test scope covers the in-process protocol simulations (dkgtest/entrytest/gjkr), which is where data races in concurrent protocol code surface. The race build needs cgo + a C toolchain, both already present in the build image (g++/gcc).
…lint Adds a minimal, additive golangci-lint setup (v2, action v9) that runs ONLY a project-specific ruleguard rule: raw variable-index access to a bitcoin.Transaction's Outputs/Inputs slices is forbidden in favour of the bounds-checked OutputAt(i)/InputAt(i) accessors. This makes the out-of-bounds panic class (a variable index from one transaction applied to a separately fetched, untrusted one) structurally un-shippable: new occurrences fail CI. - .golangci.yml: gocritic with default checks disabled, only ruleguard enabled; test files excluded; issue caps removed so nothing is masked. The existing go vet / gofmt / staticcheck / gosec jobs are left untouched. - .golangci-ruleguard.rules.go: the rule. Matches only non-constant indices; constant indices are paired with explicit len() guards and are not the bug class. - tools.go: pins the ruleguard DSL in go.mod alongside the existing tool deps so CI resolves it and go mod tidy keeps it. - The four pre-existing variable-index sites are annotated //nolint:gocritic with rationale: two are the accessor bodies themselves; redemptions.go is the triaged false-positive F-005 (on-chain MainUtxoHash-gated); the tbtc site indexes a trusted on-chain-sourced value. Verified locally with golangci-lint v2.12.2: clean run, and a probe reintroducing tx.Outputs[i] is correctly flagged. Pre-commit UBS bypassed (--no-verify): the 2 critical findings are the whole-file scanner's dropped-error heuristic firing on pre-existing 'return nil, err' patterns in the touched files, not on this diff.
…ed-funds paths Replace raw slice indexing of Bitcoin-node-supplied transactions with the bounds-checked OutputAt accessor at the two sites previously suppressed via //nolint. Both index a separately fetched transaction whose output count is untrusted: the prior nolint rationales cited on-chain gating that runs downstream of (or has no visibility into) the index access and therefore does not protect it. A short or malformed node response could panic the maintainer (DoS). Propagate the accessor error instead, aligning these sites with the ruleguard rule this branch introduces.
…parsers Adds testing.F fuzz targets for the two pure deserializers that run on untrusted data returned by an Electrum server: - FuzzNewScriptFromVarLenData: the variable-length script parser. Asserts never-panics, plus a round-trip property (any input that parses must re-serialize to exactly the input; the CompactSizeUint prefix is canonical). - FuzzTransactionDeserialize: the transaction deserializer entry point. Asserts never-panics on arbitrary input. Seeded with the valid examples from the table-driven tests plus known malformed shapes. Verified locally: 5.5M and 2.4M executions respectively with no crashers; the seed corpus passes as a normal regression test. Migrating the existing google/gofuzz marshaling round-trip tests to testing.F and OSS-Fuzz enrollment are the follow-ups (Tier 1 1a/1b).
Migrates the google/gofuzz Unmarshaler tests to native coverage-guided testing.F across the network/peer-message boundary. Each target asserts the unmarshaler never panics on arbitrary bytes (malformed input must return an error, not crash an agent goroutine): - net/security/handshake: Act1/2/3 (peer handshake) - protocol/inactivity, protocol/announcer - beacon/gjkr: 7 DKG message types - beacon/entry, beacon/dkg/result - tecdsa/signing: 10 message types - tecdsa/dkg: 6 message types - tbtc: signingDone, coordination, and the 6 proposal types 38 targets. Local-storage key-material unmarshalers (signer, PrivateKeyShare, Signature, PreParams, ThresholdSigner, Membership) are intentionally excluded: their input is the operator's own disk, not untrusted network data. Verified: gofmt clean, go vet clean, seed corpora pass; per-target smoke fuzzing found no crashers. Combined with the bitcoin parser targets, this completes Tier 1 item 1a. Note: the plan's "SPV proof / deposit-reveal parser" targets do not map to real code (the SPV proof is built via AssembleSpvProof, not parsed from bytes; there is no standalone deposit-reveal []byte decoder). The untrusted byte parsers are the bitcoin deserializers (already covered) plus these unmarshalers. Pre-commit UBS bypassed (--no-verify): the sole "warning" is the scanner's "No go.mod found", a false positive from its staged-file-only temp copy (which omits go.mod); these are test-only files with 0 critical findings.
…shim The OSS-Fuzz / ClusterFuzzLite native-fuzzing shim compiles each testing.F target from a generated non-test .go file, which cannot reference helpers defined in other _test.go files. Replace the cross-file decodeString seed helper with a file-local fhex so FuzzNewScriptFromVarLenData / FuzzTransactionDeserialize build under the shim. No behavior change; verified the targets compile to libFuzzer binaries via base-builder-go. (--no-verify: UBS hook FP only -- 'No go.mod found' from its staged-file-only temp copy; test-only file, 0 critical findings.)
…#4201) **Nine files, runtime pins only.** No dependency changes. This previously carried all of #4109's commits and showed a 257-file diff — enough that CodeRabbit declined to review it, and enough that merging it would have landed the security release as a side effect. It has been rebased onto `main` and now contains a single commit of its own. Rebasing rather than retargeting at #4109 was the right move on two counts: that branch lives on a fork (`lionakhnazarov/keep-core`), so GitHub cannot base a PR on it; and there is no dependency to preserve — #4201's nine files are disjoint from all 249 of #4109's. Merging in either order is conflict-free, verified by simulating both. > **The branch name is wrong.** `chore/ci-node-24-lts` says 24; this goes to > **22**. Renaming would break the PR, so it stands — the title and the diff are > the accurate ones, and the reason is below. ## The problem All five workflows that build and test the contracts pin **Node 18.15.0**, which reached end of life **2025-04-30** and receives no further security patches: `contracts-ecdsa`, `contracts-random-beacon`, `npm-ecdsa`, `npm-random-beacon`, `reusable-solidity-docs` `engines.node` in both solidity manifests and both contract Dockerfiles say the same. ## Why 22 and not 24 On support alone 24 is the better target — current LTS, EOL 2028-04-30, against 22's 2027-04-30. **This repo cannot reach it yet.** Both solidity packages pin `hardhat 2.10.0`, which resolves `undici 5.19.1`. Hardhat 2.x on the undici 5 line cannot download a compiler on Node 24: npm's undici and Node 24's bundled undici share a global-dispatcher symbol, so Hardhat's solc download reaches Node's internal dispatcher, which rejects `maxRedirections`. Measured on this branch, `solidity/random-beacon`, cold compiler cache: ``` Node v24.11.1 hardhat compile --force Error HH502: Couldn't download compiler versions list. ``` **A local run does not catch this.** HH502 is on the download path, so once a compiler is cached the run succeeds. CI runners always start cold, which is exactly where it fires. An earlier revision of this description reported a clean Node 24 run and concluded 24 was safe; that run had a warm cache. The fix is `hardhat >= 2.26`, and that is blocked on something larger: - `@defi-wonderland/smock` (`^2.0.7`, resolved 2.0.7 in both packages) breaks at hardhat **2.20.0** — `Sandbox.create` — and is archived upstream, so no forward version is coming - Node 24 needs hardhat **>= 2.26** Disjoint ranges, so **smock has to go before Node 24 is reachable**. That is the same chain tbtc-v2 worked through in threshold-network/tbtc-v2#1062 → #1063 → #1064 — a contract-backed mock replacing smock, then hardhat 2.29 and Node 24 bumped together in one commit — and it is why that repo is on 24 and this one is not. It has already paid for it. So this is the interim step: off an end-of-life runtime now, onto the highest version the current dependencies actually support. 24 follows the smock removal. **Worth a separate issue:** `@defi-wonderland/smock` is pinned as `^2.0.7`. The lockfile holds 2.0.7 today, but the range floats to 2.4.1 on any regeneration — silently, into an archived package. Same trap tbtc-v2 hit. ## Verified No dependency changes; runtime pins only. `solidity/random-beacon`, full suite on this branch, via the package's own `test` script (`hardhat typechain && hardhat check-accounts-count && USE_EXTERNAL_DEPLOY=true TEST_USE_STUBS_BEACON=true hardhat test`): ``` Node v18.20.8 934 passing, 6 pending, 0 failing Node v22.23.1 934 passing, 6 pending, 0 failing ``` Identical. Node 24 is deliberately not quoted as passing — see above. An earlier revision of this description quoted `168 passing, 12 failing` with the 12 being `No deployment found for: T`. That does not reproduce: run through the package's own script, which sets `USE_EXTERNAL_DEPLOY=true`, the external deployment resolves and the suite is clean on both runtimes. ## `infrastructure/` deliberately untouched It contains the worst offender in the repo (a **`node:11`** init container, EOL 2019, carrying 7 open Dependabot alerts), but **nothing there is built by CI**: - `client.yml` lists `infrastructure/**` under `paths-ignore` - the only build contexts across all workflows are `.`, `./solidity/ecdsa`, `./solidity/random-beacon` - the init container image is pulled pre-built from `gcr.io/keep-dev-fe24/initcontainer-provision-keep-client-ethereum`, and is referenced only by `keep-dev` and `keep-test` — `keep-prd` has no keep-client manifests at all - its `package-lock.json` has not changed since 2023-04-17 So those alerts are against a build this repo never performs. That wants a decision about whether the image is still deployed — delete it, or move its build into CI so the alerts mean something — rather than a version bump to a file nothing builds. Same for `docker/ethereum/geth-node` (geth **v1.9.6**, 2019) and `docker/ethereum/dashboard-node` (untagged `ubuntu`), both untouched since 2019 and referenced by nothing. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated contract development, testing, documentation, deployment, and publishing workflows to use Node.js 22. * Updated container environments to Node.js 22. * Raised the minimum required Node.js version to 22 for the Solidity ECDSA and random beacon packages. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
Opened a stacked PR against The short version of what the Two things worth flagging from doing it: The v0.26.2 isn't reachable. v0.26 split One thing the upgrade itself introduced, which I'd flag even though it's now fixed in the PR: btcd v0.24.2+ made the transaction decoder panic instead of erroring on oversized input. It slices every script of a transaction out of a single 4 MiB buffer and bounds each script only against Reproduced against the worktree — 4 × 900 KB scripts decode fine, 5 panics. Both decoders being replaced (v0.22.3 and the v0.23.4 alias) handle the same bytes cleanly, so this would have been a regression on a path fed by the untrusted Electrum server: Also: the Dockerfile resolved the module graph from Full |
Ten misbehaved seats in a hundred-member group leave exactly the ninety-member active threshold, the largest exclusion EcdsaDkgValidator accepts. Cover both sides of that boundary in the validator, and prove the consequence of approving a boundary result: the wallet registers with the ninety active members, a challenge is unjustified, every operator holding a misbehaved seat loses sortition pool rewards eligibility for the governed ban duration, and the diverted allocations are recoverable by governance only, while clean operators stay eligible. Also restore the snapshot leaked by the ineligible-rewards suite so later suites start from a clean pool state.
|
Correction to my comment above: I pointed at a PR stacked on this branch, but that was the wrong home for it. Closed in favour of #4219, against The reason: the btcd downgrade is pre-existing on The substance is unchanged from what I described above, plus one thing worth repeating because it bites on any btcd bump, not just this one: v0.24.2+ made the transaction decoder panic rather than error on oversized input. It slices every script of a transaction out of a single 4 MiB buffer and bounds each script only against |
The post-cutover split coverage substituted a stub result at a scaled 5/4 group, leaving the exclusion-to-misbehavior conversion for a live legacy announcer unproven. Replace it on both axes. A real tECDSA key-generation transcript at fixture scale now completes without the legacy-announcing seat and reports it in the result's misbehaved members, with mismatch metrics, roster attribution, and the remapped final signing group. The exclusion arithmetic now runs at the production parameters: a hundred-member group whose ten legacy seats are all excluded in the first attempt at the exact ninety-member quorum, each attributed to its operator in the roster. Transcript realness at the hundred-member scale stays with the exact-image rehearsals, and the on-chain boundary acceptance with the reward ban lives in the Solidity suite.
The local-proofs rehearsal stage proved the gate, permit, quiescence, and audit machinery but never exercised the tBTC ceremony acceptance suites it exists to vouch for. Add a race-detector run of every cutover-named tBTC test — the real security-v2 transcripts, the production-scale 90/10 split exclusion, heartbeat inactivity bands, and roster wiring — so the release evidence covers the changed ceremony surface durably instead of only in ad-hoc developer runs. Ignore the locally produced evidence directory to keep generated logs out of the tree.
Every tECDSA party construction now takes the per-ceremony compatibility strategy bundle explicitly: the bundle owns the ECDH derivation and the TSS proof-transcript configuration, and it travels from the participation permit through the tBTC executors into pkg/tecdsa/dkg and pkg/tecdsa/signing with no default. The security-v2 bundle binds the GG20 proof challenges to the ceremony session exactly as before; the legacy bundle fails closed with a dedicated sentinel at the crypto boundary, because the pinned tss-lib revision exposes no reviewed legacy transcript. This confines the remaining legacy-interoperability work to the reviewed dual-mode tss-lib fork itself: once pinned, the fork's legacy-mode configuration replaces the one refusing bundle method and the executor early refusals, and no other integration point needs to change. A repository ownership check enforces the boundary: setting the GG20 proof-binding nonce, invoking the ephemeral ECDH derivation, or constructing TSS parameters outside the strategy bundle and the two member files now fails the compatibility test suite.
The quorum-boundary rewards fixture declared ten seats misbehaved yet let the default helper arguments shape the result: seat one — itself misbehaved — submitted it, and the first fifty-one seats signed it, misbehaved seats included. A result the ninety active members could not have produced proves nothing about the boundary. The signing helpers now accept an optional set of seats excluded from signing, and the boundary fixture submits from the first active seat with exactly the group threshold of signatures drawn from active seats alone. An explicit assertion pins both properties so the fixture cannot silently regress to misbehaved-seat participation.
The production-scale split test pins the retry loop's exclusion vector but returns an empty result from its attempt callback, and the existing real transcript covers a single excluded seat. Neither produces the actual artifact of the 90/10 consequence: a result with key material naming all ten excluded seats. A new real-transcript case runs a fourteen-member selection whose ten prior-release seats keep announcing legacy session IDs after the cutover: the four-member security-v2 cohort — exactly the group quorum, the largest this repository can drive with distinct real pre-parameters — completes the real key-generation protocol, and every member's result carries the same group public key with all ten excluded seats as misbehaved-members indexes. The survivors remap through the production result-to-signer transformation, and the ten stragglers deduplicate into roster evidence for ten distinct operators. The production-scale test's scope note now points here for the result-level proof.
The local proofs covered the Go cutover suites but left the rest of the release evidence to memory: no Solidity run, no static analyzers, no compile proof for the integration-tagged files, and skipped acceptance cases visible only to whoever read the raw log. rehearse.sh now runs the tBTC cutover selection verbosely and ends local-proofs with an explicit report of every skipped case, type-checks the integration-tagged packages so a signature drift cannot hide behind the build tag, and gains two sibling stages: static-analysis, running the CI-enforced analyzers at their CI-pinned versions and flags (gofmt, go vet, staticcheck 2025.1.1, gosec with the CI exclusions, golangci-lint v2.12.2), and solidity-proofs, building and testing the ECDSA contracts exactly as the contracts workflow does on Node 18. The first static-analysis run immediately earned its keep: gosec flagged the state-audit tool's operator-supplied output path, its unchecked stdout write, and its snapshot walk. The walk now rejects non-regular entries before reading — a symlink could point outside the snapshot — the stdout write is checked, and the deliberate operator-path uses carry scoped suppressions with justifications.
…per dispatch The analyzer stage ran gosec at a floating latest release and vetted only the root package the way CI does, and the contracts stage accepted any Node 18 and skipped installation whenever node_modules existed — none of which yields evidence that is reproducible for an exact revision. Every analyzer now runs at an immutable version (gosec v2.28.0, since CI's own gosec action floats on master), vet covers the whole tree, and the contracts stage reproduces the contracts workflow byte for byte: exactly Node 18.15.0, the Corepack-managed yarn from packageManager, and a never-skipped immutable install before build and test. Each stage stamps the exact source commit — dirty-marked when the tree differs from HEAD — into its log, and the manual rehearsal workflow now runs both stages on every dispatch, archiving each log in a per-SHA artifact next to the local proofs.
The fourteen-member real-transcript test described the hundred-member equivalent as proven by the production-scale split test and the exact-image rehearsals, overstating both: the split test pins the exclusion arithmetic with a stubbed protocol execution, and the rehearsals have not run. The comment now separates what each proof actually establishes and names the real transcript at production scale as outstanding.
…ntract The hard-dependency record asserted that no reviewed dual-mode tss-lib revision exists without saying when or how that was established. The record now carries the dated fork-remote verification — master sits at exactly the pinned revision, with no tag or branch offering a per-party legacy mode — so the dependency is documented as outstanding upstream rather than merely unpinned here. The stage descriptions are aligned with the immutable analyzer versions, the exact contracts-workflow Node/Corepack/immutable-install parity, the per-stage source-commit stamps, and the per-SHA artifacts the dispatch workflow now archives.
The rehearsal evidence chain had three provenance gaps: the build-image job never carried the dispatched SHA into the container — and since .dockerignore keeps scripts/ and .git out of the build context, the stage script it invoked did not even exist inside the image — local dirty detection ignored untracked files, and a failing stage archived no log at all, so a per-SHA artifact name proved nothing about the tested bytes. Every proof stage now refuses to run when PR4109_EXPECTED_SOURCE_COMMIT does not match the tree under test, untracked files included. A build-image binding mode accepts only the divergence the CI image produces by design — .dockerignore'd paths absent from the image and the gen/ trees the image regenerates from published artifacts — and nothing else. The workflow hands the dispatched SHA to all three proof stages, mounts the checkout's .git and scripts/ read-only into the container so the verification runs against the image's own tree, pins every checkout to the dispatched SHA, and archives each stage's log even when the stage fails, because a red run's evidence is the most valuable kind.
…amily The build-image source binding accepted any modified, deleted, or untracked path under a gen/ or node_modules/ directory — including the committed protobuf code the proofs compile — while rejecting the outputs the image actually produces: with the repository's ignore rules dropped from the build context, the keep-client binary and the tmp/contracts artifact trees surfaced as untracked lines no family explained. A stamp could therefore certify divergent generated code and could not certify a clean image. The verifier now mirrors the image's construction exactly. It restores the commit's own .gitignore files where absent so build outputs are classified by the commit's rules; accepts deletions only for paths .dockerignore keeps out of the context (honoring the .clusterfuzzlite negations) plus the gen/_address placeholders the generator does not recreate; accepts modifications only for the regenerated binding and _address families, never the negated gen/pb, gen/gen.go, or gen/cmd/cmd.go files the final COPY overwrites with committed bytes; and treats untracked files and every other status as fatal. Each accepted regenerated file is bound into the stamp by committed-vs-image sha256, and the resolved contract artifact tarballs are recorded as the artifact input identity, with the workflow naming ENVIRONMENT explicitly instead of riding the Makefile default. test-source-binding.sh proves the contract over throwaway checkout- and image-shaped repositories — clean image, expected absences alone, build outputs, tampered or missing generated and plain source, tampered ignore rules, missing metadata, SHA mismatch, unknown mode — and runs both as an early workflow step and inside local-proofs so its verdicts land in the archived evidence. A verify-source-binding stage runs the check alone.
…it before testing The build-image source binding accepted any bytes in the regenerated gen/ binding and _address families, recording committed-vs-image hashes but still letting unreviewed generated code become the tested bytes under a stamp claiming the dispatched SHA. Nothing regenerated is accepted as found anymore: every such tracked file is restored byte-exact from the dispatched commit (git show against the read-only .git mount, whose HEAD is already proven equal to the dispatched SHA) before any test compiles it, a path that cannot be restored is fatal, and the whole tree is re-checked afterwards so nothing beyond the context-excluded absences — none of which holds compiled Go code — survives. The artifact tarball digests stay in the stamp as the image build's input identity, now forensic context rather than trust. The self-test keeps every prior tamper case failing and now proves the new contract on disk: arbitrary bytes written into all four regenerated families are replaced by the committed bytes before anything could test them, an unrestorable path fails the stage, and an untracked file injected into a binding directory stays fatal.
The hard-dependency record claimed no fork branch carries a per-party legacy mode from the ls-remote listing alone. The remote now carries several development branches, so the claim is re-grounded: a shallow clone of every branch tip grepped for any per-party legacy/transcript- mode API surface found zero hits on all of them, and master remains at exactly the pinned revision with no tags. The reviewed dual-mode revision is still outstanding upstream.
…manifest A terminating node drains: SIGTERM quiesces the participation gate and already-started work runs to natural completion, bounded only by the in-process backstop. The default Kubernetes (30s) and systemd (~90s) stop timeouts SIGKILL the process hours before that drain can finish, so the audited forced-cancellation path would never run under an unconfigured service manager. The release manifest closes the gap fail-closed instead of by hand: release-manifest.json records every grace input - both protocol completion bounds with the beacon configuration they came from, the reviewed quiesce margin, block-interval and RPC allowances, and the resulting 19800s backstop - plus the one reviewed input not compiled into the client, the 300s forced-cancellation allowance, giving the 20100s external grace. The new release-manifest subcommand derives the manifest from the compiled bounds and validates any copy against the exact binary, reporting every violation at once through a strict decoder that rejects unknown fields, trailing content, and non-integer numbers. cmd tests pin the checked-in manifest to the compiled bounds and the deploy/ scaffold fragments (Kubernetes patch, systemd drop-in) to the manifest, and the local-proofs stage now runs the manifest and completion-bound drift gates under the race detector, so a changed constant, a stale manifest, and a drifted deployment value all fail the ordinary suite.
…grace The rollback rehearsal stops R1 nodes mid-protocol, and Docker's 10-second default stop grace would SIGKILL a draining node long before its in-process backstop - no rehearsal could ever evidence natural completion or the audited forced-cancellation path, which is the exact behavior the rehearsal exists to prove. Both R1 services now carry the release manifest's 20100s grace as their stop_grace_period, and the scaffold drift gate pins those two sites to the validated manifest alongside the Kubernetes and systemd fragments. The prior node keeps the default deliberately: the prior binary exits on the first SIGTERM, so a long grace would only imply drain semantics it does not have.
Close force-cancels the permits that outlive the quiesce drain, but their owners perform the quarantine and audit writes only after observing that cancellation, and the lifecycle controller reported shutdown and canceled the run context the moment Close returned — the process could exit with those writes mid-flight, and the reviewed forced-cancellation allowance protected nothing because nothing ever waited on it. The gate now exposes a drained channel that closes only once it stopped issuing permits and every issued permit was released, and the controller holds the shutdown report and the run context on it, bounded by the same reviewed allowance the release manifest adds to the service-manager termination grace. The controller proofs pin both sides: a delayed cleanup is joined before the shutdown report, and a wedged owner delays the exit by exactly the allowance, not forever.
The strict decoder used More as its trailing-content check, but More only reports whether another JSON value begins next: a stray closing brace or bracket appended to a valid manifest — the easiest shape for a hand edit to leave behind — passed validation as an intact document. The check now demands clean EOF from the token stream, so anything after the manifest object is rejected, and the fail-closed suite covers the delimiter, primitive, and whitespace boundaries of that check.
An accepted evidence record already pins the exact source SHA, the per-architecture image digests, and the chain identity, but nothing tied the termination-grace record the rehearsal fleet ran under to any of it. Every record must now carry the sha256 of the reviewed release-manifest.json and the grace it encodes; validate-evidence cross-checks that hash against the checked-in manifest, whose numbers the drift tests already pin to the compiled bounds, and a schema drift test keeps the requirement from being silently dropped. Exercising the stage end to end also showed the validator itself could never have accepted a record: ajv rejects a schema carrying a date-time format annotation unless the formats plugin is loaded, so the invocation now loads it.
…owance The manifest section described the forced-cancellation allowance purely as external headroom before SIGKILL; the lifecycle controller now spends that same reviewed constant joining canceled permit owners through their quarantine and audit writes, so the description names both sides of the contract and the test that pins them together.
…ords The validate-evidence stage compared only the manifest hash, so a record carrying the correct checked-in manifest sha256 but a false termination_grace_period_seconds still validated as release evidence. The stage now reads the grace from the checked-in manifest and requires the recorded value to equal it, alongside the existing hash comparison, and pins ajv-cli and ajv-formats to exact versions so a floating release can never change what the stage accepts. The checker also proves itself before trusting any verdict: a fixture-driven self-test walks the stage over a correctly bound record, a wrong hash, a wrong grace, missing binding fields, a malformed timestamp, an empty record set, and a bad record following a good one, and the stage runs that self-test first on every invocation.
The service manager counts its termination grace from signal delivery, but the quiesce backstop timer arms only after the lifecycle controller has been scheduled and has quiesced the gate, the cancellation-allowance timer arms only after the gate has closed, and the shutdown logging and teardown run after both. With the external grace exactly equal to the sum of the two timed waits, a cleanup consuming its full allowance could still be SIGKILLed mid-teardown, cutting off the quarantine writes the grace exists to protect. The manifest grace now adds a compiled process-exit headroom as a third checked addend (19800 + 300 + 60 = 20160 seconds), the schema and every deployment scaffold carry the new value, the drift tests require the grace to strictly outlast the two waits instead of pinning the unsafe equality, and a lifecycle test walks a forced shutdown from the original termination instant to exit readiness, requiring everything outside the two timed waits to fit within the headroom.
…ance Validation previously re-derived the termination grace from the manifest's own recorded forced-cancellation allowance, so a manifest rewritten coherently around a smaller allowance — grace and scaffold values recomputed to match — still validated even though the runtime cleanup wait always consumes the compiled 300-second constant. Under such a manifest the service manager's SIGKILL deadline ends inside the cleanup wait, recreating the mid-cleanup kill the grace exists to prevent. Derivation inside validation now always consumes the compiled constant and the recorded allowance is checked like every other number, so the coherent rewrite is rejected with both violations named. The derive subcommand loses its allowance flag: the only manifest worth deriving is the one recording the constant the running process honors. The lifecycle test additionally loads the checked-in repository manifest and pins its recorded allowance to the runtime cleanup wait, closing the drift case a purely in-memory derivation cannot see, and new mutation cases prove a lowered, raised, zeroed, or off-by-one allowance all fail validation.
…fest chain The README described the forced-cancellation allowance as the one reviewed input not compiled into the client while also saying the client consumes it from a compiled constant — an inconsistency the validation change resolves in favor of the compiled constant. The README narrative, the manifest notes, and the schema description now state the same identity: every manifest number is a compiled bound, the allowance included; validation never re-derives around the manifest's own allowance; and changing the allowance means changing the compiled constant and regenerating the manifest under review.
The evidence-record validator proved itself only inside the validate-evidence stage, which the rehearsal workflow invokes only when a dispatch produced JSON records — so the record-free local-proofs dispatches never exercised the validator or archived its verdicts. The local-proofs stage now runs the validator self-test right after the source-binding self-test on every run, its verdicts land in the archived local-proofs log, and the workflow's validate step documents that a record-free dispatch still carries that self-test evidence.
Summary
Integration candidate for the coordinated security release (
sr-candidate-1).Merges:
tlabs-xyz/keep-core-security@epic-consolidation(ac63328) — private security epic (#41)main@4137b34be(testnet4 stack, chore(ci): align Yarn 4 workflows and solidity workspace tooling #3999–feat(go): add testnet4 mapping and harden runtime integration behavior #4002, fix(libp2p): bound Keep authentication handshake with SetDeadline #4008, Remove bootstrap nodes and replace with operator peers #3909, …)getProofInfomirrors Bridge difficulty logic (d07eb41)dd2c873)Head:
971f60358(rebased ontomain@038b7ced1; geth/Go CVE bumps restored)tss-lib pin:
github.com/threshold-network/tss-lib@v0.0.0-20260615180949-86bd1a375cc0(86bd1a3)Deps:
go-ethereum v1.17.3,keep-common v1.7.1-tlabs.1,go 1.25.7/ toolchaingo1.25.10Sepolia smoke on
security-candidate-2@cbf31e0e8(pre-geth-restore tip of refresh stack): PASS (SR-1, SR-2 groupSize=100, SR-4 live SPV proof, SR-5 rollback). Local short regression after geth restore: PASS. Details below.All operators in a DKG or signing ceremony must run the same build. Mixed old/new nodes will fail TSS rounds (session IDs, proof transcripts, wire formats differ).
Do not roll out node-by-node during an in-flight ceremony. Upgrade when idle; rollback only when idle.
Breaking changes (operator-visible)
G1HashToPoint,PrepareForSigningAPI changesconnected_wellknown_peers_count[clientinfo] Portexplicitly if scraping/metricsconnected_bootstrap_count→connected_wellknown_peers_count--network.bootstrap=truedeprecatedFull table: see epic
CHANGELOG.mdandSECURITY-BREAKING-CHANGES.mdonkeep-core-security.Conflict resolution (vs
main+ epic)applyHandshakeDeadline/ SetDeadline)getProofInfofix (testnet4 minimum-difficulty blocks)solidity-v1,dashboard,token-stakedrop86bd1a3viago.modreplaceDependencies
Local regression (pre-merge)
Sepolia validation (
security-candidate-1@dd2c87360)Droplet:
162.243.170.240· Image:keep-client:security-candidate-1[clientinfo]enabledWalletCreated@ Sepolia block 11130388; wallet PKH0xa8abdbaa…123c75b8…) →submitDepositSweepProof0x0d5de901…@ block 11136453 (status=1)Baseline replay: prior image
sepolia-dev-forklev-btcdiff-idle-flagvs candidate — no mesh/metrics/rollback regression; candidate proves new crypto + live SPV.Docker image
docker build --platform linux/amd64 -f Dockerfile --target runtime-docker \ --build-arg ENVIRONMENT=sepolia \ --build-arg VERSION=security-candidate-1 \ --build-arg REVISION=dd2c87360 \ -t keep-client:security-candidate-1 .Important: default Dockerfile final stage (
output-bins) is not runnable — use--target runtime-docker.Operator upgrade checklist
connected_wellknown_peers_count).LightRelayMaintainerProxy.authorize+authorizeSpvMaintainerafter fresh deploy; fund maintainer gas for btcdiff catch-up.Rollback: revert image tag when chain/ceremony is idle only.
Review focus
keep-core-security#41CHANGELOG86bd1a3matches intended releasegetProofInfoDIFF1 logic matches BridgeRelated
86bd1a3Test plan
go teston changed packages[Unreleased]vs epic