Skip to content

Commit a67cb82

Browse files
authored
canton: pin guardianGovernance on fee-charging CoreState references (#41)
Emitter.PublishMessage, EmitterRegistry.RegisterEmitter, and ReplayRootRegistry.ClaimReplayRoot authenticated a caller-supplied CoreState cid by comparing only its operator field. operator does not determine guardianGovernance (fee custody's actual trust anchor), so a real or compromised operator could co-sign a second, fully valid CoreState around an attacker-controlled guardianGovernance/feeRecipient and redirect real fee payments -- proven with a working PoC. Add a GetGuardianGovernance read choice (reusing the shape already added independently on canton-keyed-ntt, so that branch can drop its duplicate on rebase), store an immutable guardianGovernance anchor on the three fee-charging templates, and pin it before every charge alongside the existing operator check. Three new regression tests construct a genuine forged CoreState and confirm each charge now fails specifically on the new check. Claude-Session: https://claude.ai/code/session_01VFr5RtBKysvjdRN2nKmhMV
1 parent 5f72cb8 commit a67cb82

7 files changed

Lines changed: 246 additions & 44 deletions

File tree

canton/README.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,17 @@ across guardian rotation). Two things follow:
211211
- **The operator cannot forge the guardian set.** Because `guardianGovernance`
212212
is a signatory, a compromised operator cannot `create` a `CoreState` bearing
213213
the real governance party — so any fetched or disclosed `CoreState` whose
214-
`guardianGovernance` (or `operator`) field is the known anchor party is
215-
**authentic by construction**, however its cid was obtained. Consumers
216-
authenticate the payload, never the resolution path (§4.7). An operator
217-
puppet could create a `CoreState` under a *different* governance party — but
218-
its payload names the wrong parties and no consumer trusts it.
214+
`guardianGovernance` field is the known anchor party is **authentic by
215+
construction**, however its cid was obtained. Consumers authenticate the
216+
payload, never the resolution path (§4.7). An operator puppet could create a
217+
`CoreState` under a *different* governance party — but its payload names the
218+
wrong parties and no consumer trusts it. **Pinning `operator` is weaker than
219+
pinning `guardianGovernance`, and the two are not interchangeable:** a
220+
compromised operator (a single hot key) *can* co-sign a `CoreState` whose
221+
`operator` field is genuine but whose `guardianGovernance`/`feeRecipient` it
222+
controls, so an `operator`-only pin accepts a shadow `CoreState`. For the fee
223+
path — where `guardianGovernance` *is* the fee-custody anchor — only the
224+
`guardianGovernance` pin is sufficient (see below).
219225
- **Governance stays low-friction.** `guardianGovernance` actively signs only at
220226
**genesis**; each `SubmitGovernanceVAA` transition inherits its authority from
221227
the archived contract (the same authority-propagation as the `Emitter` owner
@@ -412,11 +418,20 @@ instruments is a genesis-class ceremony):
412418
(NTT §10): `payer` co-controls the publish, so it cannot be a third party
413419
who did not authorize spending its own funds. Registries charge their own
414420
operator-set `registrationFee`/`claimFee` through the same choice; at fee 0
415-
they skip the `CoreState` entirely.
421+
they skip the `CoreState` entirely. Before charging, every call site pins the
422+
`CoreState`'s authenticity by exercising `GetGuardianGovernance` and checking
423+
it against the `guardianGovernance` committed on the emitter/registry
424+
**pinning the fee-custody anchor, not merely the `operator`** so a forged
425+
`CoreState` cannot redirect the fee (see the custody bullet).
416426
- **Custody is governance-controlled**: `feeRecipient` is the
417427
`guardianGovernance` threshold party, so accrued fees move only via the
418428
k-of-n guardian ceremony the operator never touches them (the Canton
419-
analog of "fees sit in the bridge contract"). A `TransferFees` governance
429+
analog of "fees sit in the bridge contract"). This holds **even against a
430+
compromised operator hot key**: because each charge pins `guardianGovernance`
431+
(which a single operator key cannot forge it is the guardians' k-of-n
432+
external party) rather than `operator`, an operator that co-signs a shadow
433+
`CoreState` under a throwaway `guardianGovernance`/`feeRecipient` it controls
434+
is rejected at the pin before any funds move. A `TransferFees` governance
420435
VAA mints an on-ledger `FeeWithdrawalAuthorization` (signed by
421436
guardianGovernance, authority inherited from the consumed `CoreState`)
422437
recording the amount, the VAA's 32-byte recipient, and the VAA hash the

canton/core/daml/Wormhole/Core/Fees.daml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
-- structurally: a foreign allocation's sender is a required controller the
2121
-- transaction never has.
2222
--
23+
-- The "blocks theft structurally" guarantee presupposes an AUTHENTIC
24+
-- 'CoreState' — a rogue one routes @receiver = feeRecipient@ to an
25+
-- attacker-controlled party. Authenticity is now pinned by the caller before
26+
-- every charge: each call site ('PublishMessage', 'RegisterEmitter',
27+
-- 'ClaimReplayRoot') exercises 'Wormhole.Core.State.GetGuardianGovernance' and
28+
-- checks it against the @guardianGovernance@ committed on the
29+
-- emitter/registry. Pinning @guardianGovernance@ (not just the operator)
30+
-- resists a compromised operator: the operator alone does not determine
31+
-- @feeRecipient@, so a shadow 'CoreState' the operator co-signs under a
32+
-- throwaway gg is rejected before any funds move.
33+
--
2334
-- AUTHENTICITY: allocation views are template-computed and forgeable — a
2435
-- hostile template can claim any instrument while moving nothing. The pin is
2536
-- the instrument admin's SIGNATURE on the allocation contract (amulet

canton/core/daml/Wormhole/Core/Setup.daml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,20 @@ mkInitialCoreState operator guardianGovernance govChain govContract initialGuard
6060

6161
-- | The emitter-id registry the operator creates alongside the 'CoreState' at
6262
-- setup. Emitter ids are allocated sequentially; it also carries
63-
-- @guardianObserver@ for 'RegisterEmitter' to thread into each 'Emitter'.
64-
mkInitialEmitterRegistry : Party -> Party -> Int -> EmitterRegistry
65-
mkInitialEmitterRegistry operator guardianObserver registrationFee =
66-
EmitterRegistry with operator, guardianObserver, registrationFee, nextId = 0
63+
-- @guardianGovernance@ (the CoreState fee-custody anchor, pinned by
64+
-- 'RegisterEmitter' and threaded into each 'Emitter') and @guardianObserver@
65+
-- (threaded into each 'Emitter'). Pass the same @guardianGovernance@ the
66+
-- 'CoreState' was created with.
67+
mkInitialEmitterRegistry : Party -> Party -> Party -> Int -> EmitterRegistry
68+
mkInitialEmitterRegistry operator guardianGovernance guardianObserver registrationFee =
69+
EmitterRegistry with operator, guardianGovernance, guardianObserver, registrationFee, nextId = 0
6770

6871
-- | The replay-root registry (the stateless claim anchor) the operator
6972
-- creates alongside the 'CoreState' at setup. Hands a 'ReplayNode' trie root
70-
-- to any consumer that claims one (see 'Wormhole.Core.Replay').
71-
mkInitialReplayRootRegistry : Party -> Int -> ReplayRootRegistry
72-
mkInitialReplayRootRegistry operator claimFee =
73-
ReplayRootRegistry with operator, claimFee
73+
-- to any consumer that claims one (see 'Wormhole.Core.Replay'). Carries
74+
-- @guardianGovernance@ (the CoreState fee-custody anchor, pinned by
75+
-- 'ClaimReplayRoot'); pass the same @guardianGovernance@ the 'CoreState' was
76+
-- created with.
77+
mkInitialReplayRootRegistry : Party -> Party -> Int -> ReplayRootRegistry
78+
mkInitialReplayRootRegistry operator guardianGovernance claimFee =
79+
ReplayRootRegistry with operator, guardianGovernance, claimFee

canton/core/daml/Wormhole/Core/State.daml

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@
1818
-- never on how the cid was resolved: signatories cannot be forged, so a
1919
-- fetched or disclosed 'CoreState' whose @guardianGovernance@ field is the
2020
-- known governance party is authentic by construction — a compromised
21-
-- @operator@ cannot fabricate one (it lacks that signature). Two invariants:
21+
-- @operator@ cannot fabricate one (it lacks that signature). The fee-custody
22+
-- anchor is pinned exactly this way: the fee-charging call sites
23+
-- ('PublishMessage', 'RegisterEmitter', 'ClaimReplayRoot') exercise
24+
-- 'GetGuardianGovernance' and compare it against the @guardianGovernance@
25+
-- committed on the emitter/registry BEFORE charging. Pinning @guardianGovernance@
26+
-- rather than @operator@ is what makes this hold against a compromised operator:
27+
-- @operator@ alone does not determine @feeRecipient@, so an operator co-signing a
28+
-- shadow 'CoreState' under a throwaway gg would pass an operator-pin but cannot
29+
-- forge the real gg (see 'GetGuardianGovernance'). Two invariants:
2230
-- * 'CoreState' singleton: co-signed by the @operator@ and a @guardianGovernance@
2331
-- party. The @guardianGovernance@ party is the guardians' governance anchor —
2432
-- in production a decentralized-namespace external party controlled by a
@@ -189,6 +197,24 @@ template CoreState
189197
controller reader
190198
do pure operator
191199

200+
-- | Read the @guardianGovernance@ anchor off an authentic 'CoreState' — a
201+
-- STRONGER caller-side pin than 'GetOperator' for consumers that must resist
202+
-- a compromised operator, not just a third-party forgery. A 'CoreState'
203+
-- requires only SOME (operator, gg) signature pair, so an operator whose key
204+
-- is compromised can co-sign a forged 'CoreState' (with a throwaway gg party
205+
-- it controls and an attacker guardian set) that still passes an
206+
-- operator-pin. Pinning @guardianGovernance@ closes that: gg is the
207+
-- guardians' k-of-n external threshold party, whose signature a compromised
208+
-- operator (a single hot participant key) cannot produce — so no forged
209+
-- 'CoreState' can carry the real gg. Used by 'Wormhole.Ntt.Manager.Receive'
210+
-- and the 'CoreState' fee-charging pins ('PublishMessage', 'RegisterEmitter',
211+
-- 'ClaimReplayRoot').
212+
nonconsuming choice GetGuardianGovernance : Party
213+
with
214+
reader : Party
215+
controller reader
216+
do pure guardianGovernance
217+
192218
-- | Charge the governance-set per-publish fee ('messageFee'). Lives here —
193219
-- not on 'Emitter' — for two reasons: the fee amount is only knowable from
194220
-- this contract, and executing the allocation needs the FEE RECIPIENT'S
@@ -389,12 +415,15 @@ emitterAddressFor registrar owner emitterId =
389415
-- signature it lacks (see 'Emitter').
390416
template EmitterRegistry
391417
with
392-
operator : Party
393-
guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
394-
registrationFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
395-
-- units; operator-set at creation (archive +
396-
-- recreate to change), 0 for free registration
397-
nextId : Int
418+
operator : Party
419+
guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
420+
-- a signatory) in RegisterEmitter's fee charge so
421+
-- a forged CoreState under a rogue gg is rejected
422+
guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
423+
registrationFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
424+
-- units; operator-set at creation (archive +
425+
-- recreate to change), 0 for free registration
426+
nextId : Int
398427
where
399428
signatory operator
400429

@@ -415,12 +444,17 @@ template EmitterRegistry
415444
controller requester
416445
do
417446
-- Zero-fee registrations never touch the CoreState (single-disclosure
418-
-- onboarding); fee'd ones charge through it, pinning its operator so a
419-
-- fabricated CoreState cannot waive or redirect the fee.
447+
-- onboarding); fee'd ones charge through it. The gg pin runs BEFORE the
448+
-- charge: pinning @guardianGovernance@ (not just the operator) rejects a
449+
-- CoreState a compromised operator co-signed under a throwaway gg it
450+
-- controls — the operator field would match, but the fee-custody anchor
451+
-- would not, so a forged CoreState cannot redirect the registration fee.
420452
case (registrationFee > 0, coreStateCid) of
421453
(False, _) -> pure ()
422454
(True, None) -> abort "registration fee due: pass the CoreState"
423455
(True, Some csCid) -> do
456+
csGg <- exercise csCid GetGuardianGovernance with reader = requester
457+
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
424458
csOperator <- exercise csCid ChargeFee with
425459
payer = requester
426460
fee = registrationFee
@@ -429,6 +463,7 @@ template EmitterRegistry
429463
reg' <- create this with nextId = nextId + 1
430464
emitter <- create Emitter with
431465
operator
466+
guardianGovernance
432467
owner = requester
433468
guardianObserver
434469
emitterId = nextId
@@ -458,10 +493,13 @@ template EmitterRegistry
458493
-- contract grows.
459494
template ReplayRootRegistry
460495
with
461-
operator : Party
462-
claimFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument units;
463-
-- operator-set at creation (archive + recreate to
464-
-- change), 0 for free claims
496+
operator : Party
497+
guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
498+
-- a signatory) in ClaimReplayRoot's fee charge so
499+
-- a forged CoreState under a rogue gg is rejected
500+
claimFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
501+
-- units; operator-set at creation (archive +
502+
-- recreate to change), 0 for free claims
465503
where
466504
signatory operator
467505

@@ -473,13 +511,18 @@ template ReplayRootRegistry
473511
feeAllocation : Optional (ContractId Allocation, ExtraArgs)
474512
controller consumer
475513
do
476-
-- Zero-fee claims never touch the CoreState; fee'd ones charge
477-
-- through it, pinning its operator so a fabricated CoreState cannot
478-
-- waive or redirect the fee (see README §4.2).
514+
-- Zero-fee claims never touch the CoreState; fee'd ones charge through
515+
-- it. The gg pin runs BEFORE the charge: pinning @guardianGovernance@
516+
-- (not just the operator) rejects a CoreState a compromised operator
517+
-- co-signed under a throwaway gg it controls — the operator field would
518+
-- match, but the fee-custody anchor would not, so a forged CoreState
519+
-- cannot redirect the claim fee (see README §4.2).
479520
case (claimFee > 0, coreStateCid) of
480521
(False, _) -> pure ()
481522
(True, None) -> abort "claim fee due: pass the CoreState"
482523
(True, Some csCid) -> do
524+
csGg <- exercise csCid GetGuardianGovernance with reader = consumer
525+
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
483526
csOperator <- exercise csCid ChargeFee with
484527
payer = consumer
485528
fee = claimFee
@@ -498,12 +541,15 @@ template ReplayRootRegistry
498541
-- authority). Mirrors a Sui @EmitterCap@.
499542
template Emitter
500543
with
501-
operator : Party
502-
owner : Party
503-
guardianObserver : Party -- read-only guardian observer
504-
emitterId : Int -- registry-allocated
505-
emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
506-
sequence : Int -- next sequence to assign
544+
operator : Party
545+
guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
546+
-- a signatory) in PublishMessage's fee charge so
547+
-- a forged CoreState under a rogue gg is rejected
548+
owner : Party
549+
guardianObserver : Party -- read-only guardian observer
550+
emitterId : Int -- registry-allocated
551+
emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
552+
sequence : Int -- next sequence to assign
507553
where
508554
-- Owner co-signs: only a transaction carrying the owner's authority can create
509555
-- a contract whose derived address is the owner's. This is the invariant
@@ -550,6 +596,13 @@ template Emitter
550596
feeAllocation : Optional (ContractId Allocation, ExtraArgs)
551597
controller owner, payer
552598
do
599+
-- Pin the fee-custody anchor BEFORE charging: 'guardianGovernance' (not
600+
-- just the operator) is what determines where the fee lands. A
601+
-- compromised operator can co-sign a forged CoreState under a throwaway
602+
-- gg it controls — the operator field would match, but this check
603+
-- rejects it, so the message fee cannot be redirected.
604+
csGg <- exercise coreStateCid GetGuardianGovernance with reader = payer
605+
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
553606
csOperator <- exercise coreStateCid ChargeMessageFee with
554607
payer
555608
feeAllocation

canton/test/daml/Test/TestCore.daml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ setupWithInstrument mInstrument = do
5656
createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId
5757
defaultGovernanceContract [devnetGuardian] guardianObserver
5858
feeInstrument guardianGovernance)
59-
_ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianObserver 0)
60-
_ <- submit operator do createCmd (mkInitialReplayRootRegistry operator 0)
59+
_ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianGovernance guardianObserver 0)
60+
_ <- submit operator do createCmd (mkInitialReplayRootRegistry operator guardianGovernance 0)
6161
pure (operator, guardianObserver, csId)
6262

6363
-- | Register an emitter for @owner@, the production way: the requester submits
@@ -170,13 +170,15 @@ testImpersonationBlocked = do
170170
-- (a) Operator-alone forgery of Alice's address is impossible.
171171
submitMustFail operator do
172172
createCmd Emitter with
173-
operator, owner = alice, guardianObserver, emitterId = 0
173+
operator, guardianGovernance = a.guardianGovernance, owner = alice
174+
guardianObserver, emitterId = 0
174175
emitterAddress = a.emitterAddress, sequence = 0
175176

176177
-- (b) Operator + Mallory can create a same-id emitter, but its address differs.
177178
malloryEm <- submit (actAs operator <> actAs mallory) do
178179
createCmd Emitter with
179-
operator, owner = mallory, guardianObserver, emitterId = 0
180+
operator, guardianGovernance = a.guardianGovernance, owner = mallory
181+
guardianObserver, emitterId = 0
180182
emitterAddress = emitterAddressFor operator mallory 0, sequence = 0
181183
Some m <- queryContractId mallory malloryEm
182184
assert (m.emitterAddress /= a.emitterAddress)

0 commit comments

Comments
 (0)