Skip to content

Commit ad5e9c1

Browse files
committed
fix(fabtoken): validate every input of an HTLC transfer
Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 9a55663 commit ad5e9c1

5 files changed

Lines changed: 395 additions & 21 deletions

File tree

.github/workflows/nightly-fuzz.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ jobs:
7575
- name: fabtoken-action-limits
7676
pkg: ./token/core/fabtoken/v1/validator
7777
func: FuzzActionResourceLimits
78+
- name: fabtoken-transfer-htlc-validate
79+
pkg: ./token/core/fabtoken/v1/validator
80+
func: FuzzTransferHTLCValidateNoPanic
7881
- name: zkatdlog-issue-bulletproof-verifier
7982
pkg: ./token/core/zkatdlog/nogh/v1/issue
8083
func: FuzzBulletProofVerifierNoPanic

docs/services/interop.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ which makes clock synchronisation a deployment requirement and sets a lower boun
5656
deadlines. See
5757
[HTLC Deadlines and Clock Synchronisation](../security/htlc_deadline_clock_assumptions.md).
5858

59+
### HTLC Validation Rules
60+
Spending an HTLC-locked token is validated by each token driver's transfer validator
61+
(`TransferHTLCValidate` in `token/core/fabtoken/v1/validator` and
62+
`token/core/zkatdlog/nogh/v1/validator`). Both drivers enforce the same rules, so an action
63+
that is valid under one driver is valid under the other:
64+
65+
* **1-to-1 transfer only**: if *any* input of a transfer action is owned by an HTLC script,
66+
the action must have **exactly one input and exactly one output**. An HTLC-owned input may
67+
not be bundled with other inputs, and it may not fan out to several outputs. This matches
68+
the `Claim` and `Reclaim` helpers in `token/services/interop/htlc`, which each spend a
69+
single unspent token.
70+
* **Per-input checks**: the type, quantity, and owner script of the input being spent are
71+
checked against the single output; every input is validated on its own terms, never against
72+
a fixed index.
73+
* **No redeem**: the output corresponding to an HTLC spending must not be a redeem
74+
(nil owner).
75+
* **Deadline**: on a claim the script's deadline must not yet have passed; after the deadline
76+
only the sender's reclaim branch is accepted. A newly created HTLC-locked output must carry
77+
a deadline that is still in the future.
78+
* **Metadata**: a claim must publish the preimage under the script's claim key; a lock must
79+
publish the corresponding lock key. Both are counted so that a single action cannot reuse
80+
one metadata entry for several HTLC operations.
81+
5982
### Cross-Network Finality
6083
The Interop Service coordinates with the **Network Service** across multiple DLT instances. It monitors the finality of "Lock" transactions on one network before initiating corresponding "Lock" transactions on another, ensuring that the atomic swap protocol can proceed safely.
6184

token/core/fabtoken/v1/validator/validator_fuzz_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@ SPDX-License-Identifier: Apache-2.0
77
package validator_test
88

99
import (
10+
"context"
11+
"crypto"
12+
"encoding/json"
1013
"testing"
14+
"time"
1115

1216
fbactions "github.com/LFDT-Panurus/panurus/token/core/fabtoken/protos-go/v1/actions"
1317
"github.com/LFDT-Panurus/panurus/token/core/fabtoken/v1/actions"
1418
"github.com/LFDT-Panurus/panurus/token/core/fabtoken/v1/validator"
1519
"github.com/LFDT-Panurus/panurus/token/driver"
1620
driverv1 "github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1"
1721
"github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request"
22+
"github.com/LFDT-Panurus/panurus/token/services/identity"
23+
"github.com/LFDT-Panurus/panurus/token/services/identity/x509"
24+
"github.com/LFDT-Panurus/panurus/token/services/interop/encoding"
25+
"github.com/LFDT-Panurus/panurus/token/services/interop/htlc"
1826
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/proto"
1927
"github.com/stretchr/testify/require"
2028
)
@@ -135,3 +143,94 @@ func FuzzActionResourceLimits(f *testing.F) {
135143
}
136144
})
137145
}
146+
147+
// isHTLCOwner reports whether raw is a typed identity wrapping an HTLC script.
148+
func isHTLCOwner(raw []byte) bool {
149+
owner, err := identity.UnmarshalTypedIdentity(raw)
150+
if err != nil {
151+
return false
152+
}
153+
154+
return owner.Type == htlc.ScriptType
155+
}
156+
157+
// FuzzTransferHTLCValidateNoPanic fuzzes the attacker-controlled owner bytes of the inputs of a
158+
// transfer action and asserts two properties of TransferHTLCValidate:
159+
// 1. it never panics, whatever the owner bytes and however many inputs there are;
160+
// 2. it never accepts an action in which an HTLC-owned input is accompanied by any other input.
161+
//
162+
// Property 2 is the regression guard for #2025, where every check in the HTLC branch was
163+
// hardcoded to InputTokens[0], so inputs 1..n of a multi-input HTLC transfer were never
164+
// validated and a structurally invalid action was accepted with a nil error.
165+
func FuzzTransferHTLCValidateNoPanic(f *testing.F) {
166+
sender, err := identity.WrapWithType(x509.IdentityType, []byte("sender"))
167+
require.NoError(f, err)
168+
recipient, err := identity.WrapWithType(x509.IdentityType, []byte("recipient"))
169+
require.NoError(f, err)
170+
171+
preimage := []byte("preimage")
172+
hash := crypto.SHA256.New()
173+
hash.Write(preimage)
174+
img := hash.Sum(nil)
175+
script := &htlc.Script{
176+
Sender: sender,
177+
Recipient: recipient,
178+
Deadline: time.Now().Add(-1 * time.Hour), // expired -> Reclaim branch
179+
HashInfo: htlc.HashInfo{
180+
Hash: img,
181+
HashFunc: crypto.SHA256,
182+
HashEncoding: encoding.Base64,
183+
},
184+
}
185+
scriptBytes, err := json.Marshal(script)
186+
require.NoError(f, err)
187+
htlcOwner, err := identity.WrapWithType(htlc.ScriptType, scriptBytes)
188+
require.NoError(f, err)
189+
190+
// WrapWithType returns identity.Identity, a named []byte; f.Add requires the exact
191+
// parameter types of the fuzz target, hence the explicit conversions.
192+
htlcRaw, senderRaw := []byte(htlcOwner), []byte(sender)
193+
f.Add(1, htlcRaw, senderRaw) // valid single-input reclaim
194+
f.Add(2, htlcRaw, htlcRaw) // #2025 reproduction shape
195+
f.Add(2, htlcRaw, senderRaw) // htlc input plus an unrelated input
196+
f.Add(2, senderRaw, htlcRaw) // htlc input at a non-zero index
197+
f.Add(1, senderRaw, senderRaw) // no htlc at all
198+
f.Add(1, []byte{}, senderRaw) // empty owner
199+
f.Add(1, []byte("trunc"), senderRaw)
200+
f.Add(3, htlcRaw, scriptBytes) // unwrapped script bytes as an owner
201+
202+
f.Fuzz(func(t *testing.T, inputs int, owner0, owner1 []byte) {
203+
n := boundInt(inputs, 1, 8)
204+
205+
inputTokens := make([]*actions.Output, 0, n)
206+
signatures := make([][]byte, 0, n)
207+
for i := range n {
208+
owner := owner0
209+
if i%2 == 1 {
210+
owner = owner1
211+
}
212+
inputTokens = append(inputTokens, &actions.Output{Owner: owner, Type: "ABC", Quantity: "100"})
213+
signatures = append(signatures, []byte("sig"))
214+
}
215+
216+
c := &validator.Context{
217+
TransferAction: &actions.TransferAction{
218+
Outputs: []*actions.Output{{Owner: sender, Type: "ABC", Quantity: "100"}},
219+
},
220+
InputTokens: inputTokens,
221+
Signatures: signatures,
222+
MetadataCounter: make(map[string]int),
223+
}
224+
225+
var err error
226+
require.NotPanics(t, func() {
227+
err = validator.TransferHTLCValidate(context.Background(), c)
228+
})
229+
230+
// An HTLC-owned input may only be spent by a 1-to-1 transfer: as soon as there is
231+
// more than one input, the action must be rejected.
232+
if n > 1 && (isHTLCOwner(owner0) || isHTLCOwner(owner1)) {
233+
require.Error(t, err, "multi-input transfer with an htlc-owned input must be rejected")
234+
}
235+
})
236+
}

0 commit comments

Comments
 (0)