Skip to content

round: Round-scoped VTXO-tree + connector signing keys - #698

Merged
ellemouton merged 4 commits into
mainfrom
ellemouton/round-scoped-signing-keys
Jun 9, 2026
Merged

round: Round-scoped VTXO-tree + connector signing keys#698
ellemouton merged 4 commits into
mainfrom
ellemouton/round-scoped-signing-keys

Conversation

@ellemouton

@ellemouton ellemouton commented Jun 5, 2026

Copy link
Copy Markdown
Member

Part of the operator-key-rotation epic (lightninglabs/darepo#525). Pairs
with the server PR lightninglabs/darepo#533.

What

Stops the client from assuming the operator's global GetInfo key when
participating in a round. The server now derives a fresh, per-round
ephemeral VTXO-tree MuSig2 signing key (independent of its persistent
identity key) and hands it — plus the operator key its connector tree was
built with — to the client in ClientBatchInfo. This lets a client that
is still on a previous operator-key epoch agree with the server on the
tree-signing key and reconstruct the same connector outpoints, so an
operator key rotation no longer breaks in-flight round participation.

Changes

  • roundpb: add tree_cosign_key + connector_operator_key to
    ClientBatchInfo (proto + regenerated stubs).
  • round: carry both keys from CommitmentTxBuilt through
    CommitmentTxReceivedState and use them for VTXO-tree ValidatePath
    and connector-ancestry reconstruction instead of
    env.OperatorTerms.PubKey. Fall back to the global operator key when a
    field is absent (older server), so the change is wire-compatible.

The leaf policy template still commits to the global identity key — only
the tree cosigner and connector reconstruction move to the round-delivered
keys.

Testing

round unit tests pass, including a new FromProto test covering key
parsing, the absent-field fallback, and malformed-key rejection. Verified
end-to-end against the server change via systests (boarding, refresh,
leave) and the TestLeaveAfterOperatorKeyRotation itest.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces per-round operator signing keys (TreeCosignKey and ConnectorOperatorKey) to the client state machine and protobuf definitions. These keys allow the client to validate VTXO trees and reconstruct connector trees using round-specific keys instead of the global operator key, ensuring compatibility during operator key rotations. Fallbacks to the global operator key are maintained for older servers. There are no review comments, so I have no feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 16s —— View job


Review — Round-scoped VTXO-tree + connector signing keys

  • Read changed files & gather context
  • Review proto + generated stubs
  • Review round state machine changes
  • Review from_proto parsing + fallback logic
  • Review tests
  • Post review feedback

Overall this is a clean, well-scoped change. The proto additions are backward-compatible (new optional fields), the fallback to env.OperatorTerms.PubKey keeps it wire-compatible with older servers, and the comments are excellent — they explain why the keys are round-scoped, not just what they are. I traced the signing data flow and the design holds up. A few notes below; none are blockers.


✅ Design verification — downstream signing is correctly handled

My main concern on first read was that TreeCosignKey is added only to CommitmentTxReceivedState, while the downstream states (NoncesSentState, NoncesAggregatedState, PartialSigsSentState, …) also carry VTXOTreePaths and perform the actual MuSig2 tree signing — so I expected the round key to need threading through all of them.

It doesn't, and the change is correct as written: the nonce/partial-sig sessions are built from the extracted clientTree (transitions.go:2008, tree.NewSignerSession at :2026), and the cosigner set is baked into the tree nodes by the server at build time. Since the server built the tree with its per-round cosign key, the extracted clientTree already carries that key as a cosigner. So only the validation step in CommitmentTxReceivedState needs the explicit key — signing picks it up from the tree structure automatically. 👍

🔐 Security model — worth a one-line note in the invariant doc

This is the crux of the change and I believe it's sound, but it's subtle enough to be worth documenting explicitly. The client previously validated the VTXO tree against env.OperatorTerms.PubKey — a key it obtained independently via GetInfo. It now validates against a key the server hands it inside the round message. On its own that would be a downgrade (the server could supply any key + a matching tree).

What makes it safe is the property called out in the PR description: the leaf spending policy still commits to the global identity key, which the client derives itself via EffectivePkScript(). In ValidatePath, the pkScript match (tree.go:347, VerifyVTXOPath) and the cosigner check (tree.go:374, ContainsCosigner) are independent — so the trust anchor for unilateral exit remains the client-derived leaf policy, while the round cosign key only authorizes the tree transactions.

Suggestion: add a sentence to the connector-ancestry invariant in round/CLAUDE.md noting that reconstruction now uses the round-delivered connector key (falling back to the global key), and that the leaf policy key must stay the global identity key for the security model to hold. This protects a future reader from "tidying up" by moving the leaf policy onto the round key too. Fix this →

🧪 Test coverage gap (suggestion)

The new TestCommitmentTxBuiltFromProtoSigningKeys is good — it covers parsing, the absent-field fallback, and malformed-key rejection at the FromProto layer. But there's no unit-level test asserting that CommitmentTxReceivedState.ProcessEvent actually validates against s.TreeCosignKey/s.ConnectorOperatorKey when set, and falls back to env.OperatorTerms.PubKey when nil (transitions.go:1630-1636). That fallback branch is the security-relevant logic, and right now it's only exercised by the systests/itest.

A focused FSM test that threads a non-nil round key through and asserts validation uses it (e.g. validation fails when the tree is keyed to a different key than OperatorTerms.PubKey but succeeds with the round key) would lock the behavior down at the unit level. Not a blocker given the e2e coverage.

Minor nits

  • from_proto.go:275-291 — the two parse blocks are near-identical. A tiny helper (parseOptionalPubKey(field []byte, name string) (*btcec.PublicKey, error)) would remove the duplication, but it's only twice and perfectly readable as-is — purely optional.
  • btcec.ParsePubKey accepts uncompressed (65-byte) keys too, while the proto comments say "33-byte compressed". Not a correctness/security issue (the point is still validated on-curve), so no need to add a length check unless you want strict wire conformance.
  • transitions.go:1377-1379 — the ClientTrees: make(map[SignerKey]*tree.Tree) got wrapped across three lines by the formatter once ConnectorOperatorKey widened the field alignment. Cosmetic only; just flagging it's a formatter artifact, not a hand edit.

Note: I was unable to run go test/go build in this environment (commands required approval), so the above is based on static analysis. The PR reports round unit tests + systests + the TestLeaveAfterOperatorKeyRotation itest passing.
· branch ellemouton/round-scoped-signing-keys

@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch from 117b818 to 41c1398 Compare June 5, 2026 18:53

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🥦

Comment thread round/events.go
// change the tree key the client must agree on. Nil when talking to a
// server that predates this field; callers fall back to the global
// operator key.
TreeCosignKey *btcec.PublicKey

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a 3rd key here? So the vtxo sign key? or the tree sign key is the same for all users in a round?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addded below

Add two fields to ClientBatchInfo so the server can hand each round's
signing keys to clients at round time instead of clients assuming the
operator's global GetInfo key:

  - tree_cosign_key: the round's VTXO-tree MuSig2 cosigner key, derived
    fresh per round and independent of the operator identity key.
  - connector_operator_key: the operator key the round used to build its
    connector tree.

Delivering both per round lets a client that is still on a previous
operator-key epoch agree with the server on the tree-signing key and
reconstruct the same connector outpoints, so an operator key rotation
no longer breaks in-flight round participation.

Generated code regenerated from the proto change.
The client validated the VTXO tree and reconstructed the connector
tree against the operator's global GetInfo key
(env.OperatorTerms.PubKey). That breaks the moment the operator
rotates its key: a client still on the previous key would verify the
tree against the wrong cosigner and rebuild connectors from the wrong
key, so it could never complete the round.

Carry the per-round tree-cosign key and connector operator key from
ClientBatchInfo through CommitmentTxReceivedState and use them for
tree ValidatePath and connector-ancestry reconstruction. Fall back to
the global operator key when the field is absent (older server), so
the change is wire-compatible. The leaf policy template still commits
to the global identity key — only the tree cosigner and connector
reconstruction move to the round-delivered keys.
@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch 2 times, most recently from 96f6cb4 to 33e433c Compare June 9, 2026 03:54
The client rebuilt a round's VTXO tree with a per-round cosign key
(delivered in ClientBatchInfo) but a GLOBAL sweep key and delay read
from the operator's GetInfo terms. That asymmetry breaks the moment the
operator rotates its key: a client still on a previous epoch would
reconstruct the sweep branch and compute batch expiry from stale global
values that no longer match the round it joined.

Move the sweep key and delay into the per-round batch info so each round
is fully self-describing for VTXO-tree reconstruction, mirroring how the
tree cosign key and connector operator key are already delivered:

  - Add sweep_key (field 7) and sweep_delay (field 8) to
    round.v1.ClientBatchInfo.
  - Carry them through CommitmentTxBuilt and CommitmentTxReceivedState,
    threading sweep_delay through the signing-ceremony states so batch
    expiry is computed from the round's value on confirmation.
  - Move the sweep-vs-exit-delay security check out of round-actor
    construction (which no longer has a global sweep delay) into the
    per-round CommitmentTxReceivedState transition.

Remove the now-defunct global sweep terms (flag-day, no backwards
compat): drop sweep_key/sweep_delay from arkrpc GetInfoResponse and the
daemonrpc ServerInfo snapshot (both reserved to prevent reuse), and
remove SweepKey/SweepDelay from lib/types.OperatorTerms, the
GetInfo->OperatorTerms parsing, and the sdk/ark ServerInfo model.

Generated code regenerated from the proto changes.
Today the forfeit-tx penalty output pays to a BIP-86 key-spend of the
operator identity key, advertised globally as forfeit_script via the
operator GetInfo terms. That couples the penalty script to the
operator's connector/identity key and breaks the moment the operator
rotates its key: a client still on a previous epoch would build the
penalty output for the round it joined from a stale global script that
no longer matches the server.

Deliver the operator's dedicated per-round forfeit penalty key in the
batch info so each round is self-describing for forfeit-tx
reconstruction, mirroring how the tree cosign, connector, and sweep keys
are already delivered:

  - Add forfeit_key (field 9) to round.v1.ClientBatchInfo.
  - Carry it through CommitmentTxBuilt and the signing-ceremony states;
    derive the penalty script as PayToTaproot(ComputeTaprootKeyNoScript(
    forfeitKey)) at the forfeit-tx build and validate sites instead of
    reading the removed global forfeit script.

Remove the now-defunct global forfeit term (flag-day, no backwards
compat): drop forfeit_script from arkrpc GetInfoResponse and the
daemonrpc ServerInfo snapshot (both reserved to prevent reuse), and
remove ForfeitScript from lib/types.OperatorTerms, the
GetInfo->OperatorTerms parsing, and the sdk/ark ServerInfo model.

Generated code regenerated from the proto changes.
@ellemouton
ellemouton force-pushed the ellemouton/round-scoped-signing-keys branch from 33e433c to 5bdf260 Compare June 9, 2026 04:41
@ellemouton
ellemouton merged commit 65a8fcf into main Jun 9, 2026
31 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants