From bd33a90bd81ac1a6c2d2fb2f7af188a666a51ac2 Mon Sep 17 00:00:00 2001 From: Hendrik Amler Date: Mon, 30 Mar 2026 19:47:33 +0200 Subject: [PATCH] Fix multi-backend signature decoding during restore Signed-off-by: Hendrik Amler --- .../keyvalue/persistrestorer_internal_test.go | 40 +++- channel/persistence/keyvalue/restorer.go | 65 +++++-- channel/transaction.go | 37 +++- channel/transaction_dec_test.go | 51 +++++ wallet/address.go | 12 ++ wallet/backend.go | 10 + wallet/sig.go | 44 ++++- wallet/sig_test.go | 178 ++++++++++++++++++ 8 files changed, 407 insertions(+), 30 deletions(-) create mode 100644 channel/transaction_dec_test.go create mode 100644 wallet/sig_test.go diff --git a/channel/persistence/keyvalue/persistrestorer_internal_test.go b/channel/persistence/keyvalue/persistrestorer_internal_test.go index 9d412bac..3afd94a6 100644 --- a/channel/persistence/keyvalue/persistrestorer_internal_test.go +++ b/channel/persistence/keyvalue/persistrestorer_internal_test.go @@ -22,7 +22,9 @@ import ( "github.com/stretchr/testify/require" _ "perun.network/go-perun/backend/sim" // backend init - "perun.network/go-perun/channel/persistence/test" + "perun.network/go-perun/channel" + ptest "perun.network/go-perun/channel/persistence/test" + wiretest "perun.network/go-perun/wire/test" "polycry.pt/poly-go/sortedkv" "polycry.pt/poly-go/sortedkv/leveldb" "polycry.pt/poly-go/sortedkv/memorydb" @@ -44,7 +46,7 @@ func TestPersistRestorer_Generic(t *testing.T) { defer func() { require.NoError(t, db.Close()) }() pr := NewPersistRestorer(db) rng := pkgtest.Prng(t, i) - test.GenericPersistRestorerTest( + ptest.GenericPersistRestorerTest( context.Background(), t, rng, @@ -63,3 +65,37 @@ func TestChannelIterator_Next_Empty(t *testing.T) { assert.False(t, success) require.NoError(t, it.err) } + +func TestPersistRestorer_RestoreChannelRejectsUnexpectedFirstKey(t *testing.T) { + db := memorydb.NewDatabase() + pr := NewPersistRestorer(db) + defer func() { require.NoError(t, pr.Close()) }() + + var id channel.ID + require.NoError(t, pr.channelDB(id).Put("bogus", "x")) + + ch, err := pr.RestoreChannel(context.Background(), id) + require.Nil(t, ch) + require.ErrorContains(t, err, `unexpected iterator key`) + require.ErrorContains(t, err, `:bogus`) + require.ErrorContains(t, err, `expected suffix "current"`) +} + +func TestPersistRestorer_RestoreChannelRejectsTrailingBytesInCurrent(t *testing.T) { + db := memorydb.NewDatabase() + pr := NewPersistRestorer(db) + defer func() { require.NoError(t, pr.Close()) }() + + rng := pkgtest.Prng(t) + client := ptest.NewClient(context.Background(), t, rng, pr) + peer := wiretest.NewRandomAddress(rng) + ch := client.NewChannel(t, peer, nil) + + current, err := pr.channelDB(ch.ID()).GetBytes("current") + require.NoError(t, err) + require.NoError(t, pr.channelDB(ch.ID()).Put("current", string(append(current, 0xFF)))) + + restored, err := pr.RestoreChannel(context.Background(), ch.ID()) + require.Nil(t, restored) + require.ErrorContains(t, err, "decoding current incomplete") +} diff --git a/channel/persistence/keyvalue/restorer.go b/channel/persistence/keyvalue/restorer.go index 5139eaba..75d814cb 100644 --- a/channel/persistence/keyvalue/restorer.go +++ b/channel/persistence/keyvalue/restorer.go @@ -153,7 +153,8 @@ func (i *ChannelIterator) Next(context.Context) bool { } i.ch = persistence.NewChannel() - if !i.decodeNext("current", &i.ch.CurrentTXV, allowEnd) || + current, ok := i.readNextValue("current", allowEnd) + if !ok || !i.decodeNext("index", &i.ch.IdxV, noOpts) || !i.decodeNext("params", i.ch.ParamsV, noOpts) || !i.decodeNext("parent", optChannelIDDec{&i.ch.Parent}, noOpts) || @@ -161,9 +162,23 @@ func (i *ChannelIterator) Next(context.Context) bool { !i.decodeNext("phase", &i.ch.PhaseV, noOpts) { return false } + currentTXDec := channel.TransactionDec{Tx: &i.ch.CurrentTXV, Parts: i.ch.ParamsV.Parts} + if err := currentTXDec.Decode(current); err != nil { + i.err = errors.WithMessage(err, "decoding current") + return false + } + if current.Len() != 0 { + i.err = errors.Errorf("decoding current incomplete (%d bytes left)", current.Len()) + return false + } i.ch.StagingTXV.Sigs = make([]wallet.Sig, len(i.ch.ParamsV.Parts)) for idx, key := range sigKeys(len(i.ch.ParamsV.Parts)) { - i.decodeNext(key, wallet.SigDec{Sig: &i.ch.StagingTXV.Sigs[idx]}, allowEmpty) + if !i.decodeNext(key, wallet.SigDec{ + Sig: &i.ch.StagingTXV.Sigs[idx], + BackendID: participantBackendID(i.ch.ParamsV.Parts[idx]), + }, allowEmpty) { + return false + } } return i.decodeNext("staging:state", &PersistedState{&i.ch.StagingTXV.State}, allowEmpty) @@ -210,28 +225,52 @@ func (i *ChannelIterator) recoverFromEmptyIterator(key string, allowedToEnd decO // an iterator ends in the middle of decoding a channel, then the channel // iterator's error is set. Returns whether a value was decoded without error. func (i *ChannelIterator) decodeNext(key string, v interface{}, opts decOpts) bool { + buf, ok := i.readNextValue(key, opts) + if !ok { + return false + } + if buf == nil { + return true + } + origLen := buf.Len() + i.err = errors.WithMessage(perunio.Decode(buf, v), "decoding "+key) + if i.err != nil { + i.err = errors.WithMessagef(i.err, "value length %d", origLen) + return false + } + if buf.Len() != 0 { + i.err = errors.Errorf("decoding %s incomplete (%d bytes left)", key, buf.Len()) + } + + return i.err == nil +} + +func (i *ChannelIterator) readNextValue(key string, opts decOpts) (*bytes.Buffer, bool) { for !i.its[0].Next() { if !i.recoverFromEmptyIterator(key, opts) { - return false + return nil, false } } + if actual := i.its[0].Key(); !strings.HasSuffix(actual, key) { + i.err = errors.Errorf("unexpected iterator key %q, expected suffix %q", actual, key) + return nil, false + } buf := bytes.NewBuffer(i.its[0].ValueBytes()) if buf.Len() == 0 { if allowEmpty.isSetIn(opts) { - return true + return nil, true } i.err = errors.Errorf("unexpected empty value") - return false + return nil, false } + return buf, true +} - i.err = errors.WithMessage(perunio.Decode(buf, v), "decoding "+key) - if i.err != nil { - return false - } - if buf.Len() != 0 { - i.err = errors.Errorf("decoding %s incomplete (%d bytes left)", key, buf.Len()) +func participantBackendID(part map[wallet.BackendID]wallet.Address) *wallet.BackendID { + backendID, ok := wallet.SingleBackendID(part) + if !ok { + return nil } - - return i.err == nil + return &backendID } diff --git a/channel/transaction.go b/channel/transaction.go index ab701350..75f78330 100644 --- a/channel/transaction.go +++ b/channel/transaction.go @@ -31,7 +31,17 @@ type Transaction struct { Sigs []wallet.Sig } -var _ perunio.Serializer = (*Transaction)(nil) +// TransactionDec is a helper type to decode a transaction while optionally +// using participant backend IDs for signature decoding. +type TransactionDec struct { + Tx *Transaction + Parts []map[wallet.BackendID]wallet.Address +} + +var ( + _ perunio.Serializer = (*Transaction)(nil) + _ perunio.Decoder = TransactionDec{} +) // Clone returns a deep copy of Transaction. func (t Transaction) Clone() Transaction { @@ -57,6 +67,12 @@ func (t Transaction) Encode(w io.Writer) error { // Decode decodes a transaction from an `io.Reader` or returns an `error`. func (t *Transaction) Decode(r io.Reader) error { + return TransactionDec{Tx: t}.Decode(r) +} + +// Decode decodes a transaction and uses participant backend IDs for signature +// decoding when they are known. +func (d TransactionDec) Decode(r io.Reader) error { // Decode stateSet var stateSet uint8 if err := perunio.Decode(r, &stateSet); err != nil { @@ -65,7 +81,7 @@ func (t *Transaction) Decode(r io.Reader) error { switch stateSet { case 0: - t.State = nil + d.Tx.State = nil return nil case 1: default: @@ -73,12 +89,19 @@ func (t *Transaction) Decode(r io.Reader) error { } // Decode State - t.State = new(State) - if err := perunio.Decode(r, t.State); err != nil { + d.Tx.State = new(State) + if err := perunio.Decode(r, d.Tx.State); err != nil { return errors.WithMessage(err, "decoding state") } - t.Sigs = make([]wallet.Sig, t.NumParts()) - - return wallet.DecodeSparseSigs(r, &t.Sigs) + d.Tx.Sigs = make([]wallet.Sig, d.Tx.NumParts()) + // A nil or empty Parts slice means that no backend context is available for + // signature decoding, so decoding falls back to the global wallet decoder. + if len(d.Parts) == 0 { + return wallet.DecodeSparseSigs(r, &d.Tx.Sigs) + } + if len(d.Parts) != d.Tx.NumParts() { + return errors.Errorf("participant count mismatch: state has %d participants, params have %d", d.Tx.NumParts(), len(d.Parts)) + } + return wallet.DecodeSparseSigsForParts(r, &d.Tx.Sigs, d.Parts) } diff --git a/channel/transaction_dec_test.go b/channel/transaction_dec_test.go new file mode 100644 index 00000000..bd8f197b --- /dev/null +++ b/channel/transaction_dec_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 - See NOTICE file for copyright holders. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package channel_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + _ "perun.network/go-perun/backend/sim/wallet" + "perun.network/go-perun/channel" + ctest "perun.network/go-perun/channel/test" + "perun.network/go-perun/wallet" + wallettest "perun.network/go-perun/wallet/test" + pkgtest "polycry.pt/poly-go/test" +) + +func TestTransactionDecDecodeUsesParticipantBackends(t *testing.T) { + rng := pkgtest.Prng(t) + accs, addrs := wallettest.NewRandomAccounts(rng, 2, channel.TestBackendID) + params := ctest.NewRandomParams(rng, ctest.WithParts(addrs)) + state := ctest.NewRandomState(rng, ctest.WithID(params.ID()), ctest.WithNumParts(len(addrs))) + + sigs := make([]wallet.Sig, len(addrs)) + for i := range addrs { + sig, err := channel.Sign(accs[i][channel.TestBackendID], state, channel.TestBackendID) + require.NoError(t, err) + sigs[i] = sig + } + + var encoded bytes.Buffer + original := channel.Transaction{State: state, Sigs: sigs} + require.NoError(t, original.Encode(&encoded)) + + var decoded channel.Transaction + require.NoError(t, (channel.TransactionDec{Tx: &decoded, Parts: addrs}).Decode(&encoded)) + require.Equal(t, original, decoded) +} diff --git a/wallet/address.go b/wallet/address.go index 03f18fe2..a66056cb 100644 --- a/wallet/address.go +++ b/wallet/address.go @@ -83,6 +83,18 @@ func IndexOfAddrs(addrs []map[BackendID]Address, addr map[BackendID]Address) int return -1 } +// SingleBackendID returns the single backend ID contained in the participant +// map. It returns false if the participant exposes zero or multiple backends. +func SingleBackendID(part map[BackendID]Address) (BackendID, bool) { + if len(part) != 1 { + return 0, false + } + for backendID := range part { + return backendID, true + } + return 0, false +} + // CloneAddress returns a clone of an Address using its binary marshaling // implementation. It panics if an error occurs during binary (un)marshaling. func CloneAddress(a Address) Address { diff --git a/wallet/backend.go b/wallet/backend.go index cd96eb6a..1b41c929 100644 --- a/wallet/backend.go +++ b/wallet/backend.go @@ -58,6 +58,16 @@ func NewAddress(id BackendID) Address { return backend[id].NewAddress() } +// decodeSigForBackend calls DecodeSig of the given backend and returns an error +// if no backend is registered for the id. +func decodeSigForBackend(r io.Reader, id BackendID) (Sig, error) { + b := backend[id] + if b == nil { + return nil, fmt.Errorf("no wallet backend registered for id %d", id) + } + return b.DecodeSig(r) +} + // DecodeSig calls DecodeSig of all Backends and returns an error if none return a valid signature. func DecodeSig(r io.Reader) (Sig, error) { var err error diff --git a/wallet/sig.go b/wallet/sig.go index 93068852..aef617d5 100644 --- a/wallet/sig.go +++ b/wallet/sig.go @@ -47,13 +47,19 @@ const bitsPerByte = 8 // SigDec is a helper type to decode signatures. type SigDec struct { - Sig *Sig - BackendID int + Sig *Sig + // BackendID optionally selects the backend-specific signature decoder. If it + // is nil, decoding falls back to the global multi-backend decoder. + BackendID *BackendID } // Decode decodes a single signature. func (s SigDec) Decode(r io.Reader) (err error) { - *s.Sig, err = DecodeSig(r) + if s.BackendID == nil { + *s.Sig, err = DecodeSig(r) + return err + } + *s.Sig, err = decodeSigForBackend(r, *s.BackendID) return err } @@ -86,6 +92,28 @@ func EncodeSparseSigs(w io.Writer, sigs []Sig) error { // DecodeSparseSigs decodes a collection of signatures in the form (mask, sig, ...). func DecodeSparseSigs(r io.Reader, sigs *[]Sig) (err error) { + return decodeSparseSigs(r, sigs, func(r io.Reader, _ int) (Sig, error) { + return DecodeSig(r) + }) +} + +// DecodeSparseSigsForParts decodes a sparse signature collection using the +// participant backend IDs when they are known. If a participant exposes zero or +// multiple backend IDs, it falls back to the global decoder. +func DecodeSparseSigsForParts(r io.Reader, sigs *[]Sig, parts []map[BackendID]Address) (err error) { + if len(*sigs) != len(parts) { + return errors.Errorf("signature/participant count mismatch: %d != %d", len(*sigs), len(parts)) + } + + return decodeSparseSigs(r, sigs, func(r io.Reader, sigIdx int) (Sig, error) { + if backendID, ok := SingleBackendID(parts[sigIdx]); ok { + return decodeSigForBackend(r, backendID) + } + return DecodeSig(r) + }) +} + +func decodeSparseSigs(r io.Reader, sigs *[]Sig, decoder func(io.Reader, int) (Sig, error)) (err error) { masklen := int(math.Ceil(float64(len(*sigs)) / float64(bitsPerByte))) mask := make([]uint8, masklen) @@ -100,11 +128,11 @@ func DecodeSparseSigs(r io.Reader, sigs *[]Sig) (err error) { for bitIdx := 0; bitIdx < bitsPerByte && sigIdx < len(*sigs); bitIdx, sigIdx = bitIdx+1, sigIdx+1 { if ((mask[maskIdx] >> bitIdx) % binaryModulo) == 0 { (*sigs)[sigIdx] = nil - } else { - (*sigs)[sigIdx], err = DecodeSig(r) - if err != nil { - return errors.WithMessagef(err, "decoding signature %d", sigIdx) - } + continue + } + (*sigs)[sigIdx], err = decoder(r, sigIdx) + if err != nil { + return errors.WithMessagef(err, "decoding signature %d", sigIdx) } } } diff --git a/wallet/sig_test.go b/wallet/sig_test.go new file mode 100644 index 00000000..0970988b --- /dev/null +++ b/wallet/sig_test.go @@ -0,0 +1,178 @@ +// Copyright 2026 - See NOTICE file for copyright holders. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wallet_test + +import ( + "bytes" + "fmt" + "io" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "perun.network/go-perun/wallet" + "perun.network/go-perun/wire/perunio" +) + +const ( + testDecodeBackendA wallet.BackendID = 200 + testDecodeBackendB wallet.BackendID = 201 + testDecodeBackendC wallet.BackendID = 202 + testDecodeBackendD wallet.BackendID = 203 +) + +var registerDecodeTestBackends sync.Once + +type decodeTestAddress struct { + id wallet.BackendID +} + +func (a *decodeTestAddress) MarshalBinary() ([]byte, error) { return []byte{byte(a.id)}, nil } +func (a *decodeTestAddress) UnmarshalBinary(data []byte) error { + if len(data) != 1 { + return fmt.Errorf("unexpected address length %d", len(data)) + } + a.id = wallet.BackendID(data[0]) + return nil +} +func (a *decodeTestAddress) String() string { return fmt.Sprintf("decode-test-%d", a.id) } +func (a *decodeTestAddress) Equal(b wallet.Address) bool { + return a.BackendID() == b.BackendID() +} +func (a *decodeTestAddress) BackendID() wallet.BackendID { return a.id } + +type decodeTestBackend struct { + id wallet.BackendID + token byte +} + +type replayingSparseSigReader struct { + mask []byte + sig []byte + maskPos int + sigPos int + replaySig bool +} + +func (b *decodeTestBackend) NewAddress() wallet.Address { return &decodeTestAddress{id: b.id} } +func (b *decodeTestBackend) DecodeSig(r io.Reader) (wallet.Sig, error) { + var raw [1]byte + if _, err := io.ReadFull(r, raw[:]); err != nil { + return nil, err + } + if raw[0] != b.token { + return nil, fmt.Errorf("unexpected signature token %q for backend %d", raw[0], b.id) + } + return wallet.Sig{raw[0]}, nil +} + +func (*decodeTestBackend) VerifySignature([]byte, wallet.Sig, wallet.Address) (bool, error) { + return true, nil +} + +func (r *replayingSparseSigReader) Read(p []byte) (int, error) { + if r.maskPos < len(r.mask) { + n := copy(p, r.mask[r.maskPos:]) + r.maskPos += n + if n < len(p) { + m, err := r.readSig(p[n:]) + return n + m, err + } + return n, nil + } + return r.readSig(p) +} + +func (r *replayingSparseSigReader) readSig(p []byte) (int, error) { + if len(r.sig) == 0 { + return 0, io.EOF + } + n := 0 + for n < len(p) { + if r.sigPos >= len(r.sig) { + if !r.replaySig { + if n == 0 { + return 0, io.EOF + } + return n, nil + } + r.sigPos = 0 + } + m := copy(p[n:], r.sig[r.sigPos:]) + r.sigPos += m + n += m + } + return n, nil +} + +func ensureDecodeTestBackends() { + registerDecodeTestBackends.Do(func() { + wallet.SetBackend(&decodeTestBackend{id: testDecodeBackendA, token: 'a'}, int(testDecodeBackendA)) + wallet.SetBackend(&decodeTestBackend{id: testDecodeBackendB, token: 'b'}, int(testDecodeBackendB)) + wallet.SetBackend(&decodeTestBackend{id: testDecodeBackendC, token: 'x'}, int(testDecodeBackendC)) + wallet.SetBackend(&decodeTestBackend{id: testDecodeBackendD, token: 'x'}, int(testDecodeBackendD)) + }) +} + +func TestSigDecDecodeUsesBackendID(t *testing.T) { + ensureDecodeTestBackends() + + var sig wallet.Sig + backendID := testDecodeBackendB + err := wallet.SigDec{Sig: &sig, BackendID: &backendID}.Decode(bytes.NewBuffer([]byte{'b'})) + require.NoError(t, err) + require.Equal(t, wallet.Sig{'b'}, sig) + + err = wallet.SigDec{Sig: &sig, BackendID: &backendID}.Decode(bytes.NewBuffer([]byte{'a'})) + require.Error(t, err) +} + +func TestDecodeSparseSigsForPartsUsesParticipantBackends(t *testing.T) { + ensureDecodeTestBackends() + + var encoded bytes.Buffer + require.NoError(t, perunio.Encode(&encoded, []uint8{0x03})) + _, err := encoded.Write([]byte{'a', 'b'}) + require.NoError(t, err) + + sigs := make([]wallet.Sig, 2) + parts := []map[wallet.BackendID]wallet.Address{ + {testDecodeBackendA: &decodeTestAddress{id: testDecodeBackendA}}, + {testDecodeBackendB: &decodeTestAddress{id: testDecodeBackendB}}, + } + + require.NoError(t, wallet.DecodeSparseSigsForParts(&encoded, &sigs, parts)) + require.Equal(t, []wallet.Sig{{'a'}, {'b'}}, sigs) +} + +func TestDecodeSparseSigsForPartsFallsBackForMultiBackendParticipant(t *testing.T) { + ensureDecodeTestBackends() + + sigs := make([]wallet.Sig, 1) + parts := []map[wallet.BackendID]wallet.Address{{ + testDecodeBackendC: &decodeTestAddress{id: testDecodeBackendC}, + testDecodeBackendD: &decodeTestAddress{id: testDecodeBackendD}, + }} + + reader := &replayingSparseSigReader{ + mask: []byte{0x01}, + sig: []byte{'x'}, + replaySig: true, + } + require.NoError(t, wallet.DecodeSparseSigsForParts(reader, &sigs, parts)) + require.Len(t, sigs, 1) + require.NotEmpty(t, sigs[0]) +}