Skip to content

lnwallet: reject commitments missing aux HTLC signatures - #11068

Open
GeorgeTsagk wants to merge 3 commits into
lightningnetwork:masterfrom
GeorgeTsagk:auxsig-required
Open

lnwallet: reject commitments missing aux HTLC signatures#11068
GeorgeTsagk wants to merge 3 commits into
lightningnetwork:masterfrom
GeorgeTsagk:auxsig-required

Conversation

@GeorgeTsagk

Copy link
Copy Markdown
Collaborator

Description

This adds a consistency check on the auxiliary HTLC signatures received on taproot overlay channels: their count must match the number of HTLCs on the commitment, mirroring the check that already exists for the BTC-level signatures. It also makes the aux signer test doubles model the one-signature-per-HTLC behaviour of a real aux signer, which the previous mocks did not, along with a few test helpers that were not carrying the aux signature blob the way production does.

Complementary to lightninglabs/taproot-assets#2234

@litbot-9000 litbot-9000 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the three commits and ran the taproot-assets custom channel itests against this branch, as requested. I did not find a correctness or backwards-compatibility defect: the count check is gated correctly, it is equivalent to the BTC-level check that already exists two lines above it, and it is satisfied by every commitment an un-upgraded peer produces — verified empirically, not just by reading. The one thing blocking merge is the failing Lint code job. Everything below is what I actually ran.

Custom channel itest results

taproot-assets main @ a8c07ffd3e90b4c355ccf73303d1bedf57b9d94a.

How the dep was pointed at this branch. Local path replace against my checkout of this PR, rather than a pseudo-version, so that what ran is exactly the reviewed code:

git clone https://github.com/lightningnetwork/lnd.git /workspace/lnd-11068
cd /workspace/lnd-11068 && gh pr checkout 11068        # -> 8b7a73a5c86764e8eac5a9d85b7e06fb00846f45
cd /workspace/tapd-cc-11068
go mod edit -replace github.com/lightningnetwork/lnd=/workspace/lnd-11068 && go mod tidy

This PR only touches the lnd main module, so the fn/v2, cert, clock, ticker and tlv submodules stay on their released versions — no partial-replace skew.

Confirmation the binary under test really contained the change. The custom channel harness launches ../tapd-integrated-itest (itest/custom_channels/custom_channels_test.go:196), not lnd-itest, so that is the binary I checked:

$ go list -m github.com/lightningnetwork/lnd
github.com/lightningnetwork/lnd v0.21.0-beta.rc2.0.20260630214209-40c64f9db30d => /workspace/lnd-11068

$ strings -a itest/tapd-integrated-itest | grep -c "number of aux htlc sig mismatch"
1
$ strings -a itest/tapd-integrated-itest | grep -c "number of htlc sig mismatch"   # pre-existing control
1

Full suite — make itest-cc (go test ./itest/custom_channels -tags="dev monitoring ... integration itest btcd" -test.run=TestCustomChannels -test.timeout=30m):

--- PASS: TestCustomChannels (1507.59s)
ok  github.com/lightninglabs/taproot-assets/itest/custom_channels  1507.648s

27 subtests passed, 0 failed, 1 skipped, in this order: core (145.28s), large (94.44s), grouped_asset (144.86s), force_close (35.00s), group_tranches_force_close (65.17s), group_tranches_htlc_force_close (73.22s), htlc_force_close (47.73s), htlc_force_close_mpp (49.80s), liquidity_edge_cases (90.13s), liquidity_edge_cases_group (94.77s), balance_consistency (27.11s), single_asset_multi_input (18.77s), forward_bandwidth (58.82s), multi_channel_pathfinding (29.31s), strict_forwarding (59.06s), decode_asset_invoice (8.15s), self_payment (42.47s), multi_rfq (73.04s), oracle_pricing (86.14s), invoice_quote_expiry_mismatch (49.68s), limit_constraints (33.98s), fee (14.63s), breach (36.75s), forwarding_history (53.07s), passive_assets (18.05s), restart_coop_close (23.96s), list_invoices_and_payments (20.37s).

The one skip is v1_upgrade, which is skipped unconditionally in taproot-assets itself (itest/custom_channels/v1_upgrade_test.go:36, t.t.Skip("requires backward compatibility infrastructure")) — not an environment limitation on my side.

Backwards-compatibility suite — make itest-cc-compat. Since this PR makes a previously-tolerated omission fatal, I also ran the suite that puts an actual un-upgraded peer on the other end:

--- PASS: TestBackwardsCompatChannels (287.02s)
    --- PASS: TestBackwardsCompatChannels/v0.8.0 (287.02s)
ok  github.com/lightninglabs/taproot-assets/itest/custom_channels  287.071s

That harness builds tapd-integrated at tag v0.8.0 in a detached git worktree using v0.8.0's own go.mod, so the replace does not apply and that node runs an older tapd against its own older pinned lnd. I verified the asymmetry directly rather than assuming it:

$ strings -a ~/.tapd-compat-bins/tapd-integrated-v0.8.0 | grep -c "number of aux htlc sig mismatch"
0
$ strings -a ~/.tapd-compat-bins/tapd-integrated-v0.8.0 | grep -c "number of htlc sig mismatch"
1

So Charlie ran lnd without this change and Dave ran lnd with it, and Dave accepted Charlie's commitments. The scenario is not a bare open/close: it pushes two asset keysends old→new, one BTC-only keysend old→new (the non-asset-HTLC-on-an-overlay-channel case), two asset keysends new→old, then cooperatively closes with both sat and asset outputs (itest/custom_channels/compat_test.go:258-296).

No baseline run against unmodified tapd main was needed: a baseline only distinguishes a real break from a flake when something fails, and nothing failed.

What I verified

  • go test ./lnwallet/ -run 'TestAuxSig' -v -count=1 → PASS, all six new tests and the four TestAuxSigCount subtests, 0.392s.
  • go test ./lnwallet/ -count=1ok github.com/lightningnetwork/lnd/lnwallet 8.770s, no failures. Worth running because commit 1 changes restartChannel, a helper shared by many tests in that package.
  • Traced the failure path: a count mismatch reaches htlcswitch/link.go:4376l.failf(LinkFailureError{code: ErrInvalidCommitment, FailureAction: LinkFailureForceClose, ...}) at htlcswitch/link.go:4393. See the release-notes comment.
  • Checked the send side is in lockstep: sigBatch and auxSigBatch are appended adjacently in both loops of genRemoteHtlcSigJobs (lnwallet/channel.go:3462-3467 and 3544-3549) and sorted by the same OutputIndex key (lnwallet/channel.go:4228-4233), so the two counts cannot diverge, dust or not.
  • Checked the gate's premise. TapscriptRootBit is only ever set at lnwallet/reservation.go:486 when the commit type is CommitmentTypeSimpleTaprootOverlay (and at chanrestore.go:172 restoring the same), and lnd elsewhere treats HasTapscriptRoot() as the definition of an overlay channel (rpcserver.go:4919, peer/brontide.go:4204). The rationale in the new comment is accurate.
  • Tried and failed to substantiate a retransmission break: a stored CommitDiff keeps its aux blob, because serializeCommitDiff writes the CommitSig via WriteElementslnwire.WriteMessage/ReadMessage (channeldb/codec.go:386), which round-trips CustomRecords through ExtraData. So a node restarting mid-flight does not retransmit a commit_sig stripped of its aux signatures.
  • Checked the wire round-trip preserves slot count on the taproot-assets side: eHtlcPartialSigsRecord writes an explicit varint count and the decoder reads it back (tapchannelmsg/records.go:1092-1155), and processAuxSigBatch answers every job, including an empty response for a non-asset HTLC (tapchannel/aux_leaf_signer.go:278-289). One slot per non-dust HTLC, always.
  • Could not run: there is no container runtime in my environment (no docker/podman/psql), so anything behind ory/dockertest or the test_db_postgres tag did not run here. GitHub Actions covers those and they are green on this head.

Findings

Left inline, most severe first. Nothing I would block on, so this is a comment rather than a request for changes.

Posted by claudell (automated review agent) at georgetsagk's request. Findings are unverified by a human.

Comment thread lnwallet/test_utils.go Outdated
Comment thread docs/release-notes/release-notes-0.22.0.md Outdated
Comment thread lnwallet/channel_test.go Outdated
Comment thread lnwallet/channel.go
Comment thread lnwallet/channel_test.go Outdated
Three places modelled the aux signature blob less faithfully than
production does, in ways that hid whether the blob was carried at all.

MockAuxSigner.UnpackSigs returned a single slot no matter how many HTLCs
were packed. A real aux signer emits exactly one slot per HTLC, and code
that pairs the unpacked slots with HTLCs by index depends on that. The
default mock now records the slot count in the packed blob and hands
back that many slots. Since the blob is parsed into custom records, it
has to stay a canonical TLV stream, so the count travels in the dummy
record's value. Registering a function rather than a fixed value is what
lets the result depend on the input.

restartChannel rebuilt the channel with no channel options, so a restart
silently dropped the aux signer and the restarted side stopped producing
aux signatures while its peer still expected them. Production re-applies
the aux components every time a channel is loaded.

Two chan sync tests rebuilt CommitSigs from a retransmitted wire message
without its aux signature blob, which the link carries across.
The aux signature blob is peer supplied and carries no count of its own.
Verification jobs were only built for HTLCs an entry was received for,
so a peer could withhold the aux signature for an HTLC simply by
omitting it: no job was produced, nothing reported the omission, and the
commitment became our state carrying a signature we never received. At
force close the resolver then reads an empty signature out of that
HTLC's custom records, and the second level HTLC cannot be swept.

An aux signer emits exactly one entry per non-dust HTLC, in lockstep
with the BTC level signatures, so the count is now checked the same way
those already are, right beside them.

The check is gated on the tapscript root bit rather than merely on an
aux signer being configured. The signer is attached node wide, so it is
also present on plain channels, whose peers correctly send no aux
signatures at all, and requiring them there would force close those
channels. That bit is set at funding time if and only if the negotiated
commitment type is a taproot overlay, so it names exactly the channels
whose peers owe us aux signatures.
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Aug 18, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

file-based classification | 5 files | 439 additions, 9 deletions

🔴 Critical (3 files)
  • lnwallet/channel.go - core wallet/channel state machine logic (commitment transactions, channel operations)
  • lnwallet/mock.go - mock implementations within the lnwallet package, same critical package path
  • lnwallet/test_utils.go - test helper file within the lnwallet package (not a _test.go file, so classified by package path)
🟢 Low (2 files)
  • lnwallet/channel_test.go - test-only change
  • docs/release-notes/release-notes-0.22.0.md - release notes

Analysis

The PR modifies lnwallet/channel.go, which falls under the lnwallet/* package — core wallet and channel-state logic (commitment transactions, channel operations) — placing this PR at CRITICAL severity per the classification rules. The accompanying lnwallet/mock.go and lnwallet/test_utils.go files are also under the critical lnwallet/* package path (neither matches the excluded mock_*.go/*_mock.go/*_test.go patterns exactly), reinforcing this level. File/line-count thresholds are not a factor here since the package-based severity is already at the top tier. This PR should receive expert review of the wallet/channel logic.


To override, add a severity-override-{critical,high,medium,low} label.

@jtobin jtobin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

cACK, LGTM. 👍 👍

@litbot-9000

Copy link
Copy Markdown
Collaborator

@litbot-9000: review reminder
@ziggie1984: review reminder

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-critical Requires expert review - security/consensus critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants