feat: authenticate counterparty clients by content and pin them after adoption - #65
Conversation
|
Branched from |
68f0322 to
231ec34
Compare
2b71a23 to
92ea64b
Compare
Both sides previously trusted the chain-id string alone: the provider re-ran client discovery whenever its adopted client expired, frozen, or lost its counterparty, adopting any Active same-chain-id client; the consumer re-pinned ProviderClientID to whatever same-chain-id client the latest VSC packet arrived over. Anyone can permissionlessly create an IBC v2 client of a chain that reuses a chain-id string and have a relayer route packets over it, so either side could be captured by a look-alike chain. Provider: discovery now content-verifies every candidate before adoption. A candidate must be an Active tendermint client of the consumer's chain id with a registered counterparty whose latest consensus state carries the CometBFT hash of the validator set the provider itself most recently computed for that consumer (built from the stored per-consumer set, i.e. the assigned consumer keys), or the hash of the set before it: the consumer keeps running the previous set while the newest VSC packet is in flight, so both hashes are honest. The previous set's hash is retained when SetConsumerValSet rotates the stored set (new ConsumerPrevValSetHash collection, exported and restored via the new prev_consumer_valset_hash field on ConsumerState). A chain-id match that fails the content check is logged at warn level and skipped. If no candidate verifies, nothing is adopted and discovery retries next epoch: fail closed, with the liveness sweep owning removal of a consumer that never gets served. Once adopted, the client is returned unconditionally forever; expiry or freezing halts traffic instead of reopening adoption. Consumer: the ProviderClientID heal is gone. The pin is established at genesis and moves at most once, from the genesis-created client, which can never carry packets (a client created outside a MsgCreateClient has no recorded creator, so nobody can register the IBC v2 counterparty packet routing requires), to the first client that actually delivers a VSC packet. From then on any VSC arriving over a different client is rejected before any state change, with the chain-id gate kept in front as defense in depth; a missing pin also rejects, since both genesis paths establish one. Neither binding needs a vaas-level escape hatch: a dead client is revived in place by ibc-go's governance-gated MsgRecoverClient, which substitutes fresh client state under the same client id, so both the provider latch and the consumer pin survive recovery unchanged. Acknowledgements and timeouts proven for a client no consumer tracks (packets sent before their consumer was removed) are now log-only instead of erroring, so a relayer's tx is not failed over an honest stale delivery. Tests: provider discovery coverage for the forged-client rejection and its warn log, content-verified adoption, one-step tolerance, the latch holding across client death with no re-discovery, fail-closed retry, highest-height tie-break, the prev-hash rotation, the CometBFT hash equivalence (computed independently of the production helper), and the genesis round-trip of the new field; consumer coverage for the bootstrap adoption plus permanent latch, rejection of non-pinned clients before any state change, and the missing-pin rejection; the unknown-client ack and timeout tests now assert the log-only behavior. Existing tests that encoded the heal or the auto-switch were updated to assert the new semantics, and OnRecv tests now pre-pin the provider client the way genesis always does.
231ec34 to
61c2660
Compare
92ea64b to
021c4bd
Compare
| // and skipped. If several candidates verify, the one with the highest latest | ||
| // height wins; if none does, nothing is adopted and discovery retries at the | ||
| // next epoch boundary (fail closed). | ||
| func (k Keeper) discoverActiveConsumerClient(ctx sdk.Context, consumerId uint64, currentClientID string) string { |
There was a problem hiding this comment.
im not super sure about this tbf, @tbruyelle input would be useful
There was a problem hiding this comment.
producing a header carrying them requires the very validators the provider put in charge of the consumer to have signed it
This is not true for a consensus state written by MsgCreateClient, because unlike the consensus states in MsgUpdateClient, this one is not verified. So an attacker just needs to send the message below to steal the seat of an honest unadopted consumer chain:
MsgCreateClient{
client_state: {
chain_id: "<consumer chain id>",
// provide absurd height to outrank any honest candidate
// at the `height > bestHeight` check
latest_height: {<consumer revision id>, 999999999},
...
},
consensus_state: {
next_validators_hash: <hash of the provider's current consumer valset>,
root: <attacker chain's root hash>,
timestamp: now
},
}Then the attacker sends the MsgRegisterCounterParty, this is permitted since signer == creator and it's ready to be discovered by this function.
Legit consumer chain can be rejected from discovery
This one concerns the valset hashes check in clientCarriesExpectedValSetHash: it can simply reject an honest consumer from being adopted.
Until a client is adopted, the consumer is stuck on the valset the provider wrote into its genesis, no VSC packet can reach it without a client. Meanwhile the provider recomputes and re-stores the consumer valset every epoch. So any 2 changes in that computed set evict the genesis valset hash from the accepted window. From that point the legitimate client is refused at every epoch with nothing but a warn log, no VSC packet is ever delivered, lastAck never advances and finally SweepUnresponsiveConsumers stops and the deletes the consumer chain. The chain has to be registered from scratch.
Suggested fix
Instead of trying to infer which IBC client belongs to a consumer, let the consumer's owner do it manually, using MsgUpdateConsumer (only the owner can submit this message), with the addition of an optional client_id field.
It adds a manual launch step, but this allows to remove the complex discovery entirely, and so discards the related attack.
With this change a malicious owner can harm the provider valset: he could point the provider at a chain they control and forge downtime evidence against provider validators. So we need to be restrictive with its usage: it can only be used once. MsgUpdateConsumer can only set the client id if it's empty. We still need MsgRecoverClient to replace expired/frozen client.
So there's still a possible attack with this change but it is limited to the consumer chain owner.
MsgRecoverClient is bound
Something that I've discovered during the review is that MsgRecoverClient is bound. If we try to switch an existing client to another without preserving the client id, it destroys downtime history. On consumer side it's not really better, such switch will change the expected denom in the photon fee decorator, making all previously tranfered photons unusable and requiring people to transfer some other photons.
tbruyelle
left a comment
There was a problem hiding this comment.
The PR introduces a clear improvement of the client id healing process, but there's still possibility to front-run the client id pin, with the consequences of having to restart the consumer bootstrap.
Added 2 comments, one for consumer and one for provider client id healing, both avocate for an extra manual step instead of the self-healing.
| // and skipped. If several candidates verify, the one with the highest latest | ||
| // height wins; if none does, nothing is adopted and discovery retries at the | ||
| // next epoch boundary (fail closed). | ||
| func (k Keeper) discoverActiveConsumerClient(ctx sdk.Context, consumerId uint64, currentClientID string) string { |
There was a problem hiding this comment.
producing a header carrying them requires the very validators the provider put in charge of the consumer to have signed it
This is not true for a consensus state written by MsgCreateClient, because unlike the consensus states in MsgUpdateClient, this one is not verified. So an attacker just needs to send the message below to steal the seat of an honest unadopted consumer chain:
MsgCreateClient{
client_state: {
chain_id: "<consumer chain id>",
// provide absurd height to outrank any honest candidate
// at the `height > bestHeight` check
latest_height: {<consumer revision id>, 999999999},
...
},
consensus_state: {
next_validators_hash: <hash of the provider's current consumer valset>,
root: <attacker chain's root hash>,
timestamp: now
},
}Then the attacker sends the MsgRegisterCounterParty, this is permitted since signer == creator and it's ready to be discovered by this function.
Legit consumer chain can be rejected from discovery
This one concerns the valset hashes check in clientCarriesExpectedValSetHash: it can simply reject an honest consumer from being adopted.
Until a client is adopted, the consumer is stuck on the valset the provider wrote into its genesis, no VSC packet can reach it without a client. Meanwhile the provider recomputes and re-stores the consumer valset every epoch. So any 2 changes in that computed set evict the genesis valset hash from the accepted window. From that point the legitimate client is refused at every epoch with nothing but a warn log, no VSC packet is ever delivered, lastAck never advances and finally SweepUnresponsiveConsumers stops and the deletes the consumer chain. The chain has to be registered from scratch.
Suggested fix
Instead of trying to infer which IBC client belongs to a consumer, let the consumer's owner do it manually, using MsgUpdateConsumer (only the owner can submit this message), with the addition of an optional client_id field.
It adds a manual launch step, but this allows to remove the complex discovery entirely, and so discards the related attack.
With this change a malicious owner can harm the provider valset: he could point the provider at a chain they control and forge downtime evidence against provider validators. So we need to be restrictive with its usage: it can only be used once. MsgUpdateConsumer can only set the client id if it's empty. We still need MsgRecoverClient to replace expired/frozen client.
So there's still a possible attack with this change but it is limited to the consumer chain owner.
MsgRecoverClient is bound
Something that I've discovered during the review is that MsgRecoverClient is bound. If we try to switch an existing client to another without preserving the client id, it destroys downtime history. On consumer side it's not really better, such switch will change the expected denom in the photon fee decorator, making all previously tranfered photons unusable and requiring people to transfer some other photons.
| // until governance revives it in place via ibc-go's MsgRecoverClient, which | ||
| // substitutes fresh client state under the SAME client id -- the pin survives | ||
| // recovery unchanged. | ||
| func (k Keeper) enforcePinnedProviderClient(ctx sdk.Context, consumerClientID string) error { |
There was a problem hiding this comment.
Similarly to https://github.com/allinbits/vaas/pull/65/changes#r3770838508 (maybe read first), I'd advocate simplifying the client id healing here.
The vuln
Just like the provider version, this version can be front-run. The first VSC packet received by a consumer chain will pin the clientID, there's no guarantee that this packet comes from the legit provider chain, it can be any chain with the same chain id.
Due to epoch boundaries, a legit VSC packet can take time to arrive on the consumer chain after client creation. This gives even more time for an attacker to front-run. We've seen this kind of attack in the past in Cosmos for the Quicksilver launch for example (although not technically related, this demonstrates that quick and easy malicious behaviour made just to bother has been done in the past).
First, remove client creation from genesis
Client creation in genesis is more a IBC v1 leftover than a real requirement, and creates friction with v2. Because IBC v2 requires the counterparty-registration signer to be the client creator, no counterparty can ever be registered for it, so it is permanently unroutable. Note that genesis could bind a creator by calling clientKeeper.SetClientCreator, but that requires knowing in advance the relayer address and I don't like it.
Client creation in genesis also creates friction in this function because it requires the code to distinguish the unroutable genesis client from others. For instance, the check line 198 tries to identify this unroutable client by checking if it has a counterparty or not, but this can be true for other clients too, so it's kind of weak.
Hence I would remove client creation from genesis.
Then, declare manually the client instead of self-healing
Like I suggested in the provider, let's stop client id self-healing, because it has too much attack-surface. Let's register an address in the consumer genesis params, the owner again, seed by the provider as usual. This address is authored to submit a MsgSetProviderClient (maybe gov module address can too), only if the client is absent (bootstrap case).
For the reason mentioned in the provider section's comment, MsgSetProviderClient can be used only at bootstrap, swaping the client id after bootstrap has bad consequences in the consumer, like changing the fee IBC denom.
Then this function enforcePinnedProviderClient only needs to reject if there's no client id registered, which can be probably inlined in the call location instead of a having a specific function for it.
To be clear, the permanent latch this PR introduces is right and should stay, only the bootstrap adoption needs replacing.
Replaces chain-id-string trust with content verification on both sides of the
provider/consumer relationship, and pins the result.
Why
Anyone can permissionlessly create an IBC v2 client and get a relayer to route
packets through it. Previously both sides trusted the chain-id string alone:
whenever its current client was expired, frozen, or counterparty-less — so
client expiry reopened adoption forever, to any look-alike chain.
the latest validator-set update arrived over — an automatic client switch
driven by inbound traffic.
What
Provider — content-bound adoption, then a permanent latch.
discoverActiveConsumerClientadopts a candidate only if it is an activetendermint client of the consumer's chain id, counterparty-linked, and its
latest consensus state's
NextValidatorsHashequals the CometBFT hash of thevalidator set the provider itself last sent to that consumer — or of the
previous sent set, tolerating a set change whose packet is still in flight. A
chain that copies the chain-id string cannot make the provider's own validators
sign its blocks, so its consensus states cannot carry the right hash and keep
advancing. A chain-id match that fails the content check logs a warning (that
is a look-alike chain). If no candidate verifies, nothing is adopted and
discovery retries next epoch — fail closed; the liveness sweep owns a consumer
that never gets served. Once adopted, the client is latched permanently:
expiry, freezing, or counterparty loss halt traffic (packets stay queued)
rather than reopening adoption.
New state: the previous sent set's hash is retained per consumer (rotated when
the stored set is replaced) and exported/restored through a new provider
genesis field.
Consumer — the pin moves at most once, then never. The automatic re-point
is deleted. The client created from provider-produced state at genesis cannot
itself receive packets: IBC v2 routes packets only to clients whose
counterparty was registered by their creator, and a keeper-created genesis
client has no creator (verified against ibc-go v10.2.0 —
RegisterCounterpartyrequires the signer to equal the recorded creator, and counterparties are
write-once). So the pin moves exactly once, from that provably-unroutable
genesis client to the first client that actually delivers a validator-set
update — already light-client-proof-verified and counterparty-linked by IBC,
chain-id-gated by VAAS — and every later packet must arrive over the pinned
client or it is rejected before any state changes. The residual
trust-on-first-use window is the interval between consumer start and its first
delivered update; previously the pin was movable forever.
Recovery. The only re-key path on either side is IBC's governance client
recovery (
MsgRecoverClient), which substitutes client state under the sameclient id — the pin and the latch survive it. No new VAAS messages.
Relayer ergonomics. Acknowledgements and timeouts arriving for an unknown
client are now log-only instead of failing the relayer's transaction (they can
only correspond to packets sent before a consumer was removed).
The adopted client must be able to support a challenge. Downtime slashing is
falsifiable only if an accused validator can get a challenge verified, and that
verification runs against the provider's light client for the consumer — so it
must land inside the client's trusting period. Nothing enforced that: a
consumer's
unbonding_periodhad only an upper bound, the consumer advertisesthat value as its staking unbonding time precisely so relayers derive the
trusting period from it, and adoption never read the trusting period before
pinning the client permanently. A consumer could therefore hand its validators a
client whose trusting period is shorter than the window in which they would need
to defend themselves, and their slashes would execute undefended.
Adoption now rejects a candidate whose
TrustingPerioddoes not exceedDowntimeEvidenceMaxAge + DowntimeChallengeWindow(logged, and fail-closed likethe content check), and consumer creation and update reject an
unbonding_periodthat cannot produce such a client, naming the minimum. Theconsumer-chain query additionally reports the adopted client's trusting period
alongside the challengeable interval, so an operator can answer "are challenges
possible for this consumer?" in one call.
Testing
correct client adopted; previous-set tolerance; latch holds across
expiry/frozen/counterparty loss; fail-closed retry then adopt;
highest-verified-height tie-break; prev-hash rotation; hash equivalence
checked against an independently computed CometBFT hash; consumer bootstrap
adoption then latch; rejection over a non-pinned client leaves valset, pin,
staleness clock, debt, staged params, and the dedup watermark untouched;
missing pin rejects; genesis round-trip of the new field.
client content-verifies and is adopted, the consumer bootstrap-pins it, and
every downstream scenario (valset sync, debt, downtime slash, fee pool,
removal, provider genesis round-trip including the new field) is unaffected.