Skip to content

Bind SubmitFinalization signatures to authorized spends - #126

Merged
chris-ricketts merged 3 commits into
masterfrom
fix/bind-submitfinalization-signatures-to-authorized-spends
Aug 7, 2026
Merged

chris-ricketts merged 3 commits into
masterfrom
fix/bind-submitfinalization-signatures-to-authorized-spends

Conversation

@msinkec

@msinkec msinkec commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • carry the proof-authorized prevout into each finalization association
  • require finalization targets to use the same tapleaf and prevout as the signed intent proof
  • require the authorized finalization leaf to include the configured arkd signer
  • authenticate the connector input, anchor output, and forfeit value before signing
  • add regressions using two different leaves committed by the same taproot tree

Root cause

SubmitFinalization treated a valid emulator signature in an intent proof as authorization for any later transaction matching the same outpoint. signer.signInput then selected the tapleaf and transaction data from that later requester-controlled PSBT.

Impact

A requester could substitute another committed leaf containing the same tweaked emulator key and obtain a signature over a transaction that no Arkade script execution approved. The fix binds the reusable proof association to its exact leaf and prevout and ensures every finalization path retains an honest arkd cosigner.

Validation

  • go vet ./internal/application
  • go test ./internal/application with all committed application tests
  • go test ./cmd ./internal/... -run '^$' -count=1
  • focused valid, foreign-leaf, prevout-mismatch, arkd-signer, connector, anchor, and forfeit-value regressions

Closes #120

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened finalization checks to reject expired or invalid intent messages.
    • Ensured signed inputs match the intended transaction and authorized Taproot details.
    • Added validation for connector outputs, anchors, output counts, and payout values during forfeits.
    • Signing now rejects requests with missing or multiple Taproot leaf scripts.
  • Tests

    • Added coverage for valid finalizations and rejection of unauthorized or mismatched inputs and forfeits.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chris-ricketts, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c4b9bea-4175-4aaa-a784-b83e48f2ae41

📥 Commits

Reviewing files that changed from the base of the PR and between 76fd5b7 and 95ee9d7.

📒 Files selected for processing (4)
  • internal/application/finalization.go
  • internal/application/finalization_test.go
  • internal/application/signer.go
  • internal/application/signer_test.go

Walkthrough

Finalization now rejects expired or invalid intent messages before signing. It binds each signed input to the authorized Arkade signer, prevout, and Taproot leaf. Forfeits also validate connector data, anchors, output count, and payouts. Tests cover valid and rejected cases.

Changes

Finalization authorization and output validation

Layer / File(s) Summary
Authorized input signing
internal/application/finalization.go, internal/application/signer.go
Finalization validates intent expiry, Arkd signer participation, input bounds, witness UTXOs, and authorized Taproot leaf hashes for forfeits and commitments. Signing requires exactly one non-nil Taproot leaf.
Forfeit output checks
internal/application/finalization.go
Forfeit validation checks connector witness data, output count, anchor structure, payout value, and matching non-anchor leaves.
Finalization regression tests
internal/application/finalization_test.go
Tests cover valid signing, unauthorized leaves, mismatched prevouts, missing signers, connector errors, invalid anchors, incorrect payouts, expired proofs, and PSBT construction helpers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SubmitFinalization
  participant InputValidation
  participant ConnectorLookup
  participant TaprootSigner
  Client->>SubmitFinalization: submit finalization intent and PSBTs
  SubmitFinalization->>InputValidation: validate intent and authorized inputs
  InputValidation->>ConnectorLookup: resolve connector leaf output
  ConnectorLookup->>InputValidation: return validated connector output
  InputValidation->>TaprootSigner: sign validated Taproot inputs
  TaprootSigner->>SubmitFinalization: return signatures
  SubmitFinalization->>Client: return finalized signatures
Loading

Possibly related PRs

Suggested reviewers: chris-ricketts, louisinger

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR binds authorized tapleaves and prevouts, but it does not show equivalent structural and connector checks for commitment inputs required by #120. Add and test commitment-input structural and connector validation, including attacker-controlled output rejection, before signing.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: binding SubmitFinalization signatures to authorized spends.
Out of Scope Changes check ✅ Passed All changes support #120 by tightening finalization authorization, validating spend structure, and adding regression coverage.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/bind-submitfinalization-signatures-to-authorized-spends
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bind-submitfinalization-signatures-to-authorized-spends

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@msinkec
msinkec marked this pull request as ready for review August 6, 2026 11:57

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (3)
internal/application/finalization_test.go (2)

126-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend the table to cover the remaining branches.

Three branches of validateForfeitOutputs are not covered: the output-count check, the nil connector witness UTXO, and a wrong anchor value with a correct anchor PkScript.

💚 Proposed additional cases
 		{
 			name: "missing anchor",
 			mutate: func(ptx *psbt.Packet) {
 				ptx.UnsignedTx.TxOut[1].PkScript = []byte{txscript.OP_TRUE}
 			},
 			wantErr: "is not the anchor output",
 		},
+		{
+			name: "wrong anchor value",
+			mutate: func(ptx *psbt.Packet) {
+				ptx.UnsignedTx.TxOut[1].Value = txutils.ANCHOR_VALUE + 1
+			},
+			wantErr: "is not the anchor output",
+		},
+		{
+			name: "missing connector witness utxo",
+			mutate: func(ptx *psbt.Packet) {
+				ptx.Inputs[1].WitnessUtxo = nil
+			},
+			wantErr: "does not match the connector tree",
+		},
+		{
+			name: "extra output",
+			mutate: func(ptx *psbt.Packet) {
+				ptx.UnsignedTx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{txscript.OP_TRUE}})
+			},
+			wantErr: "expected 2 outputs",
+		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/application/finalization_test.go` around lines 126 - 153, Extend the
tests table for validateForfeitOutputs with cases covering an invalid output
count, a nil connector WitnessUtxo, and an anchor output retaining the correct
PkScript but using an incorrect value. Set each case’s mutate function and
expected error substring to exercise the corresponding validation branch.

63-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case where the PkScript differs.

The "different prevout" case changes only Value. The bytes.Equal(input.WitnessUtxo.PkScript, a.prevout.PkScript) branch in validateFinalizationInput stays uncovered. A regression that drops the PkScript comparison would still pass this table.

💚 Proposed additional case
 		{
 			name:           "different prevout",
 			proofLeaf:      authorizedLeaf,
 			commitmentLeaf: authorizedLeaf,
 			commitment:     &wire.TxOut{Value: prevout.Value - 1, PkScript: prevout.PkScript},
 			wantErr:        "witness UTXO does not match intent proof",
 		},
+		{
+			name:           "different pkScript",
+			proofLeaf:      authorizedLeaf,
+			commitmentLeaf: authorizedLeaf,
+			commitment:     &wire.TxOut{Value: prevout.Value, PkScript: []byte{txscript.OP_TRUE}},
+			wantErr:        "witness UTXO does not match intent proof",
+		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/application/finalization_test.go` around lines 63 - 69, Add a
separate table case for “different prevout” in the finalization tests that keeps
the value unchanged but uses a different PkScript, expecting “witness UTXO does
not match intent proof.” This must exercise the bytes.Equal PkScript comparison
branch in validateFinalizationInput.
internal/application/finalization.go (1)

291-317: 🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy lift

Bind the connector tree inside SubmitFinalization.

leafOutput only checks that the connector outpoint exists in finalization.ConnectorTree; it does not check that that tree is the commitment transaction for finalization.CommitmentTx. The gRPC handler currently calls verifyTreeRelatedToCommitment before SubmitFinalization, but the binding check belongs in the application API so the authorization invariant always applies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/application/finalization.go` around lines 291 - 317, Update
SubmitFinalization to verify that finalization.ConnectorTree is bound to
finalization.CommitmentTx before using leafOutput or authorizing finalization.
Reuse verifyTreeRelatedToCommitment for this validation, and ensure the API
rejects mismatched trees independently of the gRPC handler’s existing check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/application/finalization.go`:
- Around line 150-158: Update the Taproot leaf validation around tapLeaf and
input.TaprootLeafScript to inspect every entry instead of only index 0. Require
exactly one authorized leaf or verify that all entries match a.TapLeaf(), while
preserving the existing missing-script and mismatch error behavior.
- Around line 66-70: Update validateForfeitOutputs and its caller in
BatchFinalization to validate TxOut[0].PkScript against the service’s expected
forfeit script derived from configuration or arkdPubKey. Reject any mismatching
destination before accepting the forfeit, while preserving the existing output
value and anchor validations.

---

Nitpick comments:
In `@internal/application/finalization_test.go`:
- Around line 126-153: Extend the tests table for validateForfeitOutputs with
cases covering an invalid output count, a nil connector WitnessUtxo, and an
anchor output retaining the correct PkScript but using an incorrect value. Set
each case’s mutate function and expected error substring to exercise the
corresponding validation branch.
- Around line 63-69: Add a separate table case for “different prevout” in the
finalization tests that keeps the value unchanged but uses a different PkScript,
expecting “witness UTXO does not match intent proof.” This must exercise the
bytes.Equal PkScript comparison branch in validateFinalizationInput.

In `@internal/application/finalization.go`:
- Around line 291-317: Update SubmitFinalization to verify that
finalization.ConnectorTree is bound to finalization.CommitmentTx before using
leafOutput or authorizing finalization. Reuse verifyTreeRelatedToCommitment for
this validation, and ensure the API rejects mismatched trees independently of
the gRPC handler’s existing check.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 343ce729-7dea-4b6b-a400-8e4ef9a880ba

📥 Commits

Reviewing files that changed from the base of the PR and between 79b6ccb and d7689ee.

📒 Files selected for processing (2)
  • internal/application/finalization.go
  • internal/application/finalization_test.go

Comment thread internal/application/finalization.go Resolved
Comment thread internal/application/finalization.go Resolved

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — #126

The vulnerability is real and the fix is structurally correct: signedInputAssociation now carries the proof's exact prevout and TapLeaf hash, both are verified before every sign call, and the arkd cosigner requirement is enforced on the proof's decoded closure. I have two mandatory items before this should land.


[C1] Forfeit validateFinalizationInput path is entirely untested

SubmitFinalization calls association.validateFinalizationInput(forfeit, inputIndex, s.arkdPubKey) inside the forfeit loop (the new block after if !ok { continue } in the forfeit iteration). Neither TestSubmitFinalizationValidatesAuthorizedInput (which passes no Forfeits) nor TestValidateForfeitOutputs (which calls validateForfeitOutputs directly, bypassing SubmitFinalization) exercises this code path.

The four rejection cases — foreign-leaf, prevout mismatch, no-arkd-signer, and connector mismatch — are tested only for the commitment-tx branch. The forfeit branch runs a different call site with a different *psbt.Packet argument (forfeit vs. finalization.CommitmentTx) and is completely dark.

A future refactor could delete or skip these checks on the forfeit path and the test suite would still pass. For a security fix this is not acceptable. A SubmitFinalization call with a real forfeit and a minimal connector tree is needed, covering at least: valid forfeit, foreign-leaf rejection, prevout-mismatch rejection.


[C2] leafOutput returns an aliased pointer into the TxTree (finalization.go)

output := node.Root.UnsignedTx.TxOut[outpoint.Index]
// ...
return output

wire.MsgTx.TxOut is []*wire.TxOut, so TxOut[outpoint.Index] is a live pointer into the tree's slice. This pointer is held as connectorOutput and then read in validateForfeitOutputs. If the connector tree is concurrently accessed or mutated between leafOutput and the value/pkscript comparisons, the validation sees inconsistent data. Return a defensive copy:

out := *node.Root.UnsignedTx.TxOut[outpoint.Index]
return &out

This is one extra allocation per forfeit and eliminates the aliasing entirely.


[N1] Zero-fee enforcement deserves a comment (finalization.go)

expectedValue := vtxoPrevout.Value + connectorOutput.Value - txutils.ANCHOR_VALUE

With ANCHOR_VALUE = 0 this reduces to vtxo + connector, enforcing zero in-band miner fees. This is correct — the P2A ephemeral anchor carries fees via CPFP — but the formula looks like it may have intended to leave room for fees. A one-line comment (// ANCHOR_VALUE is 0; fees are paid by CPFP through the ephemeral anchor) prevents future maintainers from treating this as a bug.


What is correct

  • Core binding is sound: proof prevout + TapHash are stored in signedInputAssociation and compared byte-for-byte before every sign call in both the forfeit and commitment-tx branches.
  • containsPubKey(a.script.ClosurePubKeys(), arkdPubKey) checks the closure pubkeys decoded from the proof's tapscript — not the finalization PSBT's self-reported fields — so the arkd-cosigner requirement cannot be spoofed by crafting a finalization PSBT alone.
  • The defensive PkScript copy in getSignedInputAssociations (append([]byte(nil), input.WitnessUtxo.PkScript...)) is correct.
  • TaprootLeafScript[0] is used consistently in ReadArkadeScript, validateFinalizationInput, and signInput; there is no inconsistency between what is validated and what is signed.
  • Connector WitnessUtxo is validated against the on-tree connectorOutput value and pkscript, closing the connector-substitution vector.
  • validateForfeitOutputs correctly checks output count before indexing TxOut[1].
  • The deprecated-signer path still flows through validateFinalizationInput, so key rotation does not create a bypass window.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
internal/application/finalization_test.go (1)

391-391: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Exercise ambiguous Taproot leaf descriptors.

Line 391 always inserts one non-nil descriptor. The tests cannot verify rejection when the input has no non-nil descriptor or has multiple non-nil descriptors.

Make the fixture accept a leaf slice. Add failure cases that assert an error and no TaprootScriptSpendSig.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/application/finalization_test.go` at line 391, Update the test
fixture around Taproot leaf setup to accept a configurable leaf slice instead of
always inserting one non-nil descriptor. Add cases for no non-nil descriptor and
multiple non-nil descriptors, asserting each returns an error and leaves
TaprootScriptSpendSig unset.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/application/finalization_test.go`:
- Line 391: Update the test fixture around Taproot leaf setup to accept a
configurable leaf slice instead of always inserting one non-nil descriptor. Add
cases for no non-nil descriptor and multiple non-nil descriptors, asserting each
returns an error and leaves TaprootScriptSpendSig unset.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3eee6ce0-2447-494b-b2d7-d155ba45d995

📥 Commits

Reviewing files that changed from the base of the PR and between d7689ee and 64c6557.

📒 Files selected for processing (2)
  • internal/application/finalization_test.go
  • internal/application/signer.go

@msinkec

msinkec commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 64c6557.

C1 — valid. Added TestSubmitFinalizationValidatesForfeit, which drives SubmitFinalization with a real forfeit and a single-leaf connector tree: valid forfeit, foreign leaf, prevout mismatch, no-arkd-signer, connector outside the tree.

C2 — not applying. The *tree.TxTree is built per request in parseTxTree and used by a single goroutine; nothing else retains a reference, so there is no concurrent mutation for the copy to defend against.

N1 — skipped, repo convention is to keep comments to a minimum.

Also hardened signInput to reject inputs declaring more than one tapleaf, and filled the uncovered validateForfeitOutputs branches (output count, nil connector witness UTXO, wrong anchor value) plus the prevout PkScript comparison.

Integration suite is green against arkd v0.9.13 (21/21), which confirms real clients never attach more than one leaf script per input.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Status: 1 new commit (64c6557) since last pass — C1 fixed, C2 still open (author declined), N1 still open (author declined by convention). No regressions introduced. Two minor new findings.


Prior findings closure

C1 — Fixed (64c6557). TestSubmitFinalizationValidatesForfeit drives SubmitFinalization with a real intent proof, a real forfeit PSBT, and a single-leaf connector tree. It covers all four rejection cases I required: foreign-leaf, prevout-mismatch, no-arkd-signer, and connector-outside-tree. The signed-output assertion on the success case is also present. This was the mandatory blocker; it is resolved.

C2 — Still open. leafOutput (finalization.go) still returns a raw pointer into the tree's TxOut slice — not a copy. Author's rebuttal (single-goroutine, tree built per-request) is accurate for the current implementation, so there is no live race today. I continue to flag it because the defensive copy is one allocation and permanently eliminates the aliasing if the request model ever changes. Marking as low-risk/informational this pass.

N1 — Still open. The zero-fee comment was skipped per repo convention. Acknowledged.


New findings in this diff

[N2] validateFinalizationInput guard is inconsistent with the hardened signInput guard — finalization.go + signer.go

signInput now rejects any input where len(TaprootLeafScript) != 1 (signer.go:31). validateFinalizationInput, added in the previous commit, still uses the old guard: len == 0 || [0] == nil (finalization.go, in the validateFinalizationInput block). An input carrying two leaf scripts passes validateFinalizationInput and is only caught later by signInput with a different error string.

There is no security hole — the input is still rejected before any signature is produced. The concern is maintenance: the two functions are now out of step, and the validation layer no longer fully describes what the signing layer accepts. If signInput is later relaxed or the ordering changes, the gap becomes exploitable.

Fix: update validateFinalizationInput's guard to len(input.TaprootLeafScript) != 1 || input.TaprootLeafScript[0] == nil to match signInput.

[N3] The new len != 1 rejection path in signInput has no test — finalization_test.go

TestSubmitFinalizationValidatesForfeit and TestSubmitFinalizationValidatesAuthorizedInput both wire exactly one leaf script per input (the happy path). There is no test case that submits a forfeit or commitment PSBT where len(TaprootLeafScript) == 0 or len == 2 and asserts that an error is returned without a signature. The new guard in 64c6557 is therefore not exercised by the test suite.

A minimal table entry in either test would close this:

{
    name:      "two taproot leaf scripts on forfeit input",
    // ... same valid proof / prevout / connector,
    // after building forfeit:
    // forfeit.Inputs[0].TaprootLeafScript = append(
    //     forfeit.Inputs[0].TaprootLeafScript,
    //     forfeit.Inputs[0].TaprootLeafScript[0],
    // )
    wantErr: "expected exactly 1 taproot leaf script",
},

What is correct in this diff

  • The len != 1 change in signInput is the right fix for the ambiguity: silently signing index 0 when multiple leaves are declared could permit a requester to smuggle a leaf that passes validation but differs from the one that is signed.
  • The incremental diff additions to TestValidateForfeitOutputs (missing connector witness UTXO, extra output, wrong anchor value) close the branch-coverage gaps I called out in the CodeRabbit thread.
  • The new "different prevout script" case in TestSubmitFinalizationValidatesAuthorizedInput closes the partial-match gap (value matches but script does not) that was missing before.
  • finalizationConnectorTree correctly uses chainhash.NewHashFromStr(ptx.UnsignedTx.TxID()) rather than computing the hash from the wire-serialized tx directly, which matches how the production code identifies tree nodes.
  • The test for "connector outside the tree" uses wire.OutPoint{Hash: chainhash.Hash{9}}, which cannot collide with the connector tx hash (derived from a fresh PSBT), so the test is deterministic.

N2 and N3 are minor; N2 is the more important of the two because it leaves the validation and signing layers out of step. I am requesting changes on that basis, but both items are small fixes.

@chris-ricketts
chris-ricketts force-pushed the fix/bind-submitfinalization-signatures-to-authorized-spends branch from 76fd5b7 to bd9764c Compare August 7, 2026 13:26

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Status: 1 new commit (76fd5b7, "enforce intent proof validity window on finalization") since last pass. C1 remains closed. C2, N1, N2, N3 are all still open — none were touched by this commit. One new finding (N4).


Prior findings closure

C1 — Closed (64c6557). Test coverage for the forfeit finalization path was added and is adequate.

C2 — Still open. leafOutput (finalization.go:310) still returns a raw pointer into the live TxOut slice (return output where output := node.Root.UnsignedTx.TxOut[outpoint.Index]). Not touched by 76fd5b7. The aliasing risk is low-severity under the current single-goroutine model, but the defensive copy remains a one-line fix.

N1 — Still open. Zero-fee path skipped by repo convention. Acknowledged.

N2 — Still open. validateFinalizationInput (finalization.go, in the signedInputAssociation method added in the PR) guards with len(input.TaprootLeafScript) == 0 || input.TaprootLeafScript[0] == nil, while signInput (signer.go:38) now guards with len(input.TaprootLeafScript) != 1 || input.TaprootLeafScript[0] == nil. An input with two leaf scripts passes the validation layer but is rejected by the signing layer with a different error string. The two guards are now permanently out of step. 76fd5b7 does not change either function. Fix: change validateFinalizationInput to len(input.TaprootLeafScript) != 1.

N3 — Still open. No test submits a forfeit or commitment PSBT where len(TaprootLeafScript) == 0 or len == 2 and asserts an error without a signature. The len != 1 guard in signInput (signer.go:38) added in this PR is not exercised by any test case.


New findings in 76fd5b7

[N4] validateMessage rejects nil Intent.Message with a misleading error — finalization.go:22

if err := validateMessage(finalization.Intent.Message); err != nil {
    return nil, fmt.Errorf("invalid message: %w", err)
}

validateMessage is called with finalization.Intent.Message, which is an interface (IntentMessage). When the field is the zero value (nil interface), the type switch falls through to default and returns "unsupported intent message type". The caller sees "invalid message: unsupported intent message type" rather than anything that identifies the message as absent.

This is consistent with the identical guard in SubmitIntent (intent.go:19), so it is not a regression specific to this commit. However, 76fd5b7 is the first time the guard appears in the SubmitFinalization path, and any existing BatchFinalization caller that omits Intent.Message (nil) will now receive this error where previously the call succeeded. The change is intentionally breaking for that class of caller; the error message makes the cause opaque. A nil guard (if finalization.Intent.Message == nil { return nil, fmt.Errorf("missing intent message") }) before the validateMessage call would make the failure mode self-describing.

Additionally: validateMessage skips the expiry check when expireAt == 0, making any message with no ExpireAt field set unconditionally valid (intent.go:125: if expireAt > 0 && ...). This is existing behavior carried over from SubmitIntent, not introduced here, but it now also gates SubmitFinalization signing — a proof with ExpireAt == 0 is permanently authorizing. Worth a deliberate protocol decision record.


What is correct in 76fd5b7

  • Placing validateMessage before getSignedInputAssociations is correct: it short-circuits on expired proofs without doing any crypto work, and it closes the replay window that existed when an expired intent message could still authorize new forfeit or commitment signatures.
  • TestSubmitFinalizationRejectsExpiredProof is well-constructed: it builds a valid proof (cryptographic check would pass), overwrites the message with a one-minute-past expiry, calls SubmitFinalization, asserts the specific error string, and asserts that no TaprootScriptSpendSig was written to the PSBT. This is the right shape for a security regression test.
  • Updating signedFinalizationIntent to carry a one-hour-future message keeps all pre-existing tests valid under the new guard without requiring per-test changes.

@chris-ricketts
chris-ricketts force-pushed the fix/bind-submitfinalization-signatures-to-authorized-spends branch from d42fc79 to 95ee9d7 Compare August 7, 2026 14:17
@chris-ricketts
chris-ricketts merged commit c6df705 into master Aug 7, 2026
6 checks passed

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Status: 1 new commit (95ee9d7, "rebase(master): merged tests and deduped") since last pass — C1 remains closed; C2, N1, N2, N3 still open; N4 regressed (the expiry check present in 76fd5b7 is gone in the rebased head). Two new findings (C3, N5). Note: PR is already merged — findings below require a follow-up fix commit.


Prior findings closure

C1 — Closed (64c6557). TestSubmitFinalizationValidatesForfeit remains in the rebased test file as TestSubmitFinalizationValidatesForfeitOutputs. Coverage of the forfeit finalization path is intact.

C2 — Still open. leafOutput (finalization.go:383) still returns output := node.Root.UnsignedTx.TxOut[outpoint.Index] — a live pointer into the tree's slice, not a copy. Not touched by 95ee9d7. Low-risk under the single-goroutine model but still trivially fixable.

N1 — Still open. No zero-fee comment added. Acknowledged per repo convention.

N2 — Still open. validateFinalizationInput (finalization.go:183) guards with len(input.TaprootLeafScript) == 0 || input.TaprootLeafScript[0] == nil; signInput (signer.go:39) guards with len(input.TaprootLeafScript) != 1 || input.TaprootLeafScript[0] == nil. The two-leaf case passes the validation layer and is only caught at signing with a different error. Both functions are present unchanged in 95ee9d7.

N3 — Still open. The new TestSignInputRejectsUncommittedTaprootLeaf tests a swapped leaf and a nil control block, but no test submits a PSBT with len(TaprootLeafScript) == 0 or len == 2 through validateFinalizationInput. The len != 1 guard in signInput added in this PR remains untested at the finalization-validation layer.

N4 — Regressed. validateMessage(finalization.Intent.Message) was added in 76fd5b7 and was the subject of a positive finding in the last review. The rebased head (95ee9d7) has dropped it entirely from SubmitFinalization. See C3 below.


New findings in 95ee9d7

[C3] validateMessage expiry check dropped from SubmitFinalization — finalization.go

finalization.go currently has no call to validateMessage. It was added in 76fd5b7 and TestSubmitFinalizationRejectsExpiredProof exercised it. Both are absent from 95ee9d7 — the rebase merged two branches and the expiry guard did not survive.

Impact: An intent proof with a past ExpireAt now authorises new forfeit and commitment-tx signatures indefinitely. The new indexer check (C3-adjacent) requires the commitment txid to be known to arkd and is a valuable gate, but it does not enforce the proof's time window. A proof created for a round that completed three months ago — and whose ExpireAt has long since passed — will be accepted by SubmitFinalization as long as its commitment tx still appears in the indexer.

Restore before any further merges:

// At the top of SubmitFinalization, before getSignedInputAssociations:
if err := validateMessage(finalization.Intent.Message); err != nil {
    return nil, fmt.Errorf("invalid message: %w", err)
}

And restore TestSubmitFinalizationRejectsExpiredProof.

[N5] Duplicate sighash guard in signInput — signer.go:26-30 and signer.go:56-61

// line 26-30: first check
if input.SighashType != txscript.SigHashDefault && input.SighashType != txscript.SigHashAll {
    return fmt.Errorf("unsupported sighash type 0x%02x, cannot sign", uint32(input.SighashType))
}
// ... taproot check, leaf-count check, VerifyTaprootLeafCommitment ...
// line 56-61: second identical check
if input.SighashType != txscript.SigHashDefault && input.SighashType != txscript.SigHashAll {
    return fmt.Errorf("unsupported sighash type 0x%02x, cannot sign", byte(input.SighashType))
}

The second block (lines 56-61) is dead code: any sighash type that fails the first check at line 28 never reaches line 56. This is a rebase artefact from two PRs (#130 and the current) each adding the check independently. No security impact — the first check is strict — but the dead block causes TestSignInputRejectsPartialSighashTypes to test the first occurrence only. Remove lines 54-61.


What is correct in 95ee9d7

  • arkade.VerifyTaprootLeafCommitment (signer.go:48-52) is a genuine new security check: it verifies the declared TapLeaf is actually committed by the prevout's taptree using the control block, closing the leaf-swap vector where a requester could replace the leaf script after the prevout check. TestSignInputRejectsUncommittedTaprootLeaf covers the committed case, the swapped-to-outside-tree case, and the missing-control-block case.
  • The indexer-based commitment-tx gate (retryWithBackoff against GetCommitmentTx, finalization.go:37-49) is a sound additional check: it prevents a replayed or fabricated proof from authorising a commitment tx that arkd never built. TestSubmitFinalizationRejectsUnknownCommitmentTx overrides commitmentTxRetryConfig correctly via t.Cleanup and asserts no signature is produced.
  • TestSignInputRejectsPartialSighashTypes and TestSignInputRejectsUnsafeSighashTypes together cover all ANYONECANPAY and partial-commit sighash types.
  • TestSubmitFinalizationValidatesForfeitOutputs is a comprehensive table-driven set covering the canonical forfeit, redirected value, split outputs, replaced anchor, short-pay, inflated prevout, forged connector prevout, and out-of-tree connector.
  • s.activeDeprecatedSigners() replacing s.deprecatedSigners is correct; the active-only filter ensures key-rotation gaps do not create bypass windows.

C3 is a mandatory fix. N5 is a cleanup. Both should land as a follow-up before the next protocol round.

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.

Bind SubmitFinalization signatures to authorized spends

4 participants