fix(core): resolve the first usable public parameters, not the first retrievable - #2283
Conversation
0237ead to
fab0226
Compare
adecaro
left a comment
There was a problem hiding this comment.
Review: does this fully address #2281?
Approve — the fix does exactly what #2281 asks: newTMS now walks every public-parameters source and tries to instantiate with each one instead of stopping at the first source that merely retrieved bytes.
Checked out 2281_newtms_first_usable_pp at fab0226e and verified:
$ go build ./... # clean
$ go vet ./token/core/ # clean
$ gofmt -l -s token/core/tms.go token/core/tms_test.go # clean
$ go test -count=1 ./token/core/ # ok, coverage: 96.8% of statements
$ go test -race -count=1 ./token/core/... # ok (all subpackages)
$ golangci-lint run ./token/core/... # 0 issues in changed files
I also grepped every caller of TMSProvider.Update/newTMS/ErrTMSNotFound across the repo (token/services/network/fabric/endorsement/fsc/service.go, responder.go) to confirm none of them pattern-match on the old error text or rely on newTMS mutating opts.PublicParams — none do, and opts is passed by value into the exported methods anyway, so that mutation was never externally visible even pre-PR.
Reverting token/core/tms.go to bdcd99d (pre-PR) and re-running TestTMSProviderPublicParamsFallback fails four of its subtests, and one panics uncaught (panic: invalid curve id, since base has no recover()), confirming the tests pin the bug rather than the diff. I also checked out the intermediate commit 2c78ccf2f (commit 1 only, without commit 2's Update-specific restriction) and confirmed TestTMSProviderUpdateRejectsUnusablePublicParams fails there — commit 1 alone would let Update silently fall back to stale storage; commit 2 exists specifically to close that. Both commits are load-bearing and correctly ordered.
checks, utest, itest (base is main, so the full matrix ran), bench-check, cgo-check, CodeQL, and DCO are all green at fab0226e.
What it fixes
| # | Finding | Verified |
|---|---|---|
| 2281.1 | newTMS committed to the first source that returned bytes, even if the driver then rejected them, instead of trying the next source |
✅ traced in newTMS (tms.go:279-346): retrieval and instantiation now happen per-source inside the same loop iteration, and a rejection continues the walk |
| 2281.2 | A stale/malformed local copy (storage or config) could shadow the authoritative network fetcher | ✅ TestTMSProviderPublicParamsFallback/unusable_storage_public_params_fall_back_to_the_fetcher + my own revert test above |
| 2281.3 | Failures from skipped sources were not surfaced, making the eventual error unhelpful | ✅ retrievalErrs/instantiationErrs are aggregated via errors.Join and every source's failure is present in the final error (TestTMSProviderPublicParamsSourceErrors) |
All three rows check out; no gaps.
Non-blocking
- Design note:
GetTokenManagerService/Updatehold the single provider-widem.lock(not one per TMS key) for the entirenewTMSwalk. Before this PR that walk normally did one retrieval; now, in the recovery scenario this fix targets, it can do several driver deserializations plus a network fetch, all while every other network/channel/namespace key is blocked. Not a regression from this PR (the coarse lock predates it) and the trade-off is reasonable for a rare path — flagging in case a future PR wants per-key lock granularity if this walk gets exercised more than occasionally. - Nice catch, not a finding: the PR incidentally fixes a pre-existing typo in
ppFromStorage's error string ("no public parames found"→"no public params found"), and the test suite (priority matrix, concurrency, dedup-by-digest,Updatekeeping the cached service on a rejected update) goes well beyond what #2281 asked for.
Recommendation
Nothing to change before merge. The Design note above is worth a one-line mention in docs/public_parameters.md's existing "Resolution vs. update" section if you want to preempt the question later, but it's not blocking.
fab0226 to
6eb0b25
Compare
df350d2 to
0785d1e
Compare
…retrievable
newTMS resolved the public parameters and instantiated the token service as two
independent steps: the walk over the four sources (caller options, local
storage, local configuration, network fetcher) returned the bytes of the first
source that had any, and a single NewTokenService call then decided pass or fail
for the whole TMS.
So the walk moved on only when a source returned no bytes - never when a source
returned bytes the driver rejects (driver name/version skew such as "invalid
identifier, expecting [dlog.v1], got [dlog.v2]", an unparsable container, an
out-of-range curve id). A retrievable but unusable copy permanently shadowed
every lower-priority source, including the authoritative one on the ledger, so
during a public parameters upgrade - where the on-chain parameters are updated
before the nodes are - the affected TMS could not be instantiated at all, and
could not recover without manual configuration or database intervention.
Diagnosis was equally hard: only one source ever reached the driver, and the
retrieval failures of the earlier ones were logged and dropped, so nothing
identified which source supplied the offending bytes.
For each source, newTMS now retrieves the public parameters and immediately
tries to instantiate the token service with them. The first source the driver
accepts wins; a source that yields nothing, fails to yield, or yields public
parameters the driver rejects falls through to the next one. loadPublicParams is
gone.
- sources are named (options, storage, configuration, fetcher) so every failure
is attributable, and the returned error aggregates each source's failure
tagged with the source name.
- ErrTMSNotFound semantics are preserved: returned if and only if no source
produced any bytes ("TMS not set up yet"), decided by an explicit count of the
sources that did, never when a source produced unusable bytes. The callers
that branch on it are unaffected.
- no duplicate work: public parameters byte-identical to a blob a
higher-priority source already tried are not deserialized twice, and the
fetcher is still never called when a higher-priority source succeeds.
- newTokenService converts a panic raised while deserializing stale or
attacker-controlled public parameters into an error, logging the stack at the
recover site. Without it, one panicking blob would still shadow the
authoritative public parameters - the same failure mode.
- walking every source reaches two paths a short-circuiting source used to hide:
ppFromConfig dereferenced a nil driver.Configuration returned with a nil
error, and ppFromStorage a nil storage. Both are now reported as errors.
An Update is deliberately excluded from the walk. It carries the public
parameters the TMS is to adopt, so it resolves from the options source alone and
accepts only what it was given: if the driver rejects them, Update fails and the
cached service is left running and cached, untouched. Falling back would report
success while the node kept running on an older copy, after having already
unloaded the service it had - and since the options an update is built from
carry no PublicParamsFetcher, such a fallback could only ever land on a stale
local copy, the opposite of what the walk exists for.
docs/public_parameters.md gains a "Resolution Order" section documenting the
four sources, the retrieve-and-try-in-order semantics and the
error/ErrTMSNotFound contract, a "Resolution vs. update" subsection, and a note
that a single provider-wide lock is held across the whole walk (tracked
separately in #2306).
Fixes #2281
Signed-off-by: AkramBitar <akram@il.ibm.com>
0785d1e to
76e1629
Compare
Fixes #2281
Problem
Old behaviour — stops at the first source that returns bytes, not at the first usable one:
newTMS(token/core/tms.go) resolved the public parameters and instantiated the token service as two independent steps:loadPublicParamswalked the four sources (caller options -> local storage -> local configuration -> network fetcher) and returned the bytes of the first one that had any, and a singleNewTokenServicecall then decided pass or fail for the whole TMS.So the walk moved on only when a source returned no bytes — never when a source returned bytes the driver rejects (driver name/version skew such as
invalid identifier, expecting [dlog.v1], got [dlog.v2], an unparsable container, an out-of-range curve id, ...). A retrievable but unusable copy permanently shadowed every lower-priority source, including the authoritative one on the ledger.Result: a stale or corrupted local copy permanently blocks the authoritative copy on the ledger. During a public parameters upgrade — where the on-chain parameters are updated before the nodes are — the affected TMS cannot be instantiated at all, and cannot recover without manual configuration or database intervention.
Diagnosis was equally hard: only one source ever reached the driver, and the retrieval failures of the earlier ones were
Warnf'd and dropped, so nothing identified which source supplied the offending bytes.Fix
New behaviour — retrieve and try each source in order, first accepted wins:
For each source,
newTMSretrieves the public parameters and immediately tries to instantiate the token service with them, inside the loop. The first source the driver accepts wins. A source that yields nothing, fails to yield, or yields public parameters the driver rejects falls through to the next one.loadPublicParamsis gone.ppSource{name, retrieve}:options,storage,configuration,fetcher) so every failure is attributable; the returned error aggregates each source's failure, tagged with the source name.ErrTMSNotFoundsemantics preserved — returned if and only if no source produced any bytes ("TMS not set up yet"), never when a source produced unusable bytes. It is now decided by an explicit count of the sources that produced bytes. The callers that branch on it (token/services/network/fabric/endorsement/fsc) are unaffected.newTokenServiceconverts a panic raised while deserializing stale or attacker-controlled public parameters into an error, and logs the stack at the recover site. Without it, one panicking blob would still shadow the authoritative public parameters — the same failure mode.ppFromConfigdereferenced anildriver.Configurationreturned with anilerror (the pre-existing test even carried a "To avoid panic in ppFromConfig" comment), andppFromStoragea nil storage. Both are now reported as errors.Bonus fix (commit 2) — the
UpdatepathAfter commit 1:
Update(newPP)-> driver rejects newPP -> falls through to local storage -> builds a service from an older copy -> reports success, having already unloaded the service that was running. Reached from the ledger setup listener (token/services/network/fabric/network.go), that turned a node which merely needed a binary upgrade into a node silently running on superseded public parameters, withManagementServiceProvider.Updatealso shutting down the selector manager and clearing its cache on the way. On the update path the fallback cannot even reach the ledger, because the service options an update is built from carry noPublicParamsFetcher— so it could only ever land on a stale local copy, the opposite of what the walk is for.After commit 2:
Update(newPP)-> driver rejects newPP -> fails, and leaves the running service untouched.Updateresolves from source 1 alone (updatePublicParamsSources) and accepts only what it was given. No fallback.Also in commit 2:
newTokenServiceturns a panic into an error and the walk moves on, so the stack was lost and nothing downstream could say where a driver panicked. It is now logged at the recover site.ErrTMSNotFoundis decided by a count, not inferred fromlen(instantiationErrs). The previous form relied on "a duplicate implies an earlier failed attempt", an invariant nothing enforces.opts.PublicParamsis gone. Every caller passes a pointer to its own by-value copy, so the documented post-condition was unobservable.Tests
TestTMSProviderPublicParamsFallbackintoken/core/tms_test.go, 9 sub-cases:options/storage/configurationhold unusable public parameters;ErrTMSNotFound;ErrTMSNotFound, and every source's reason is reported;nilconfiguration returned with anilerror does not panic;These sub-cases were verified to fail against the pre-fix
newTMS(four fail outright, the panic case aborts the run) and to pass after it.For the
Updatepath:TestTMSProviderUpdateRejectsUnusablePublicParams(the update fails, and never reads storage or queries the network) andTestTMSProviderUpdateKeepsTheCachedServiceOnFailure(the running service is neither unloaded nor evicted). These replaceTestTMSProviderUpdateFallback, which asserted the behaviour commit 1 had introduced.TestTMSProviderConcurrentGetcovers 32 concurrent resolvers, asserting the TMS is instantiated once and the network queried once.Verification
go test ./token/core/passes, also under-race.golangci-lint run token/core/...-> 0 issues;make checks-> exit 0.go test ./token/...shows one unrelated pre-existing failure,TestTranslatePathintoken/services/identity/config: it asserts the checkout path contains "panurus" and the local worktree is named differently. It fails identically on unmodifiedmain.Docs
docs/public_parameters.mdgains a "Resolution Order" section under Discovery and Fetching, documenting the four sources, the retrieve-and-try-in-order semantics, and the error/ErrTMSNotFoundcontract — plus a "Resolution vs. update" subsection stating that the walk does not apply toUpdate, and a note that source 4 requires a configuredPublicParamsFetcher.Known limitation (not addressed here)
GetTokenManagerServiceholds the provider-wide write lock across the whole walk, which may now include the ledger fetch, so a hung fetch blocks TMS lookups for every namespace on the node. This is pre-existing — the fetcher was always reachable under that lock — but this change makes reaching it more likely. Fixing it means per-key creation locking, which changes when the post-init callback can run concurrently, so it is tracked separately in #2306 rather than folded in here.