Skip to content

Commit 1a7f69b

Browse files
authored
fix(ttx): harden responder views against adversarial input (#1944) (#1946)
Signed-off-by: Angelo De Caro <adc@zurich.ibm.com>
1 parent 309f72e commit 1a7f69b

17 files changed

Lines changed: 700 additions & 50 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ cmd/token_validation_service/out/
1313
/.antigravitycli/
1414
/site/
1515
coverage.out
16+
/.codex/

docs/services/ttx.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ When a transaction is created, it:
5656
* Assigns a unique Transaction ID.
5757
* Registers a cleanup hook in the FSC view context to ensure resources (like locked tokens) are released if the transaction fails.
5858

59+
## Responder Threat Model
60+
61+
TTX responder views cross a network trust boundary. A remote initiator can control envelope bytes, transaction encodings, TMS and wallet identifiers, recipient and composite-identity structures, signature requests, spend requests, and the timing or omission of protocol messages. Responders must treat all of these inputs as hostile.
62+
63+
The assets protected at this boundary are:
64+
65+
- service availability, including freedom from panics and disproportionate parsing work;
66+
- token-owner, auditor, and node signing capabilities;
67+
- recipient identities, audit information, and other response data;
68+
- local transaction, wallet, signer, and endpoint-binding state.
69+
70+
Responder processing follows four security requirements:
71+
72+
1. Validate message type, encoding, structure, and protocol state before using nested fields.
73+
2. Authenticate the party or identity authorized for an operation before releasing signatures or recipient data.
74+
3. Validate the complete structure before mutating local state, and bind acknowledgements to the transaction that was reviewed.
75+
4. Return errors for malformed or inconsistent input instead of panicking.
76+
77+
TMS implementations, token drivers, local wallets, and configured infrastructure services are trusted. Application code remains responsible for business-policy decisions, such as confirming that a multisig or policy spend transaction consumes exactly the token named in the earlier request. Applications also control which callers can invoke responder views and which local wallets those callers may select.
78+
79+
The complete boundary inventory and security goals are in the [TTX Responder Threat Model](ttx/ttx_responder_security.md).
80+
5981
## Identity Management
6082

6183
To issue or transfer tokens, the initiator must acquire the recipient's identity. The TTX service provides interactive protocols for this purpose.
@@ -78,9 +100,9 @@ Wire messages use JSON sessions (`token/services/utils/json/session`); the diagr
78100
- If `recipientRequest.RecipientData != nil`, the responder checks `OwnerWallet.Contains` for `RecipientData.Identity`, then sends a **slim acknowledgement** (`RecipientResponse` with no `RecipientData`, only a `Signature`) back on the session (echo path). The initiator already holds the full `RecipientData` and uses its own copy.
79101
- If `recipientRequest.RecipientData == nil`, the responder calls `OwnerWallet.GetRecipientData` and sends a full `RecipientResponse` carrying the wallet-produced `RecipientData` plus a `Signature` (fresh path).
80102

81-
**Nonce / Signature Binding.** Every `RecipientRequest` (and `ExchangeRecipientRequest`) carries a cryptographic nonce (`NonceSize` bytes) generated by the initiator. The responder signs an *attestation message* — a DER-encoded (`encoding/asn1`) structure that captures every field of the received request (TMSID, wallet id, identity, multisig flag, policy, nonce) together with the session id and the context id — using the private key corresponding to the returned identity (obtained via `tms.SigService().GetSigner`). The initiator rebuilds the same structure and verifies the signature with `tms.SigService().OwnerVerifier` **before** registering the identity. The session id and context id are propagated in the message header, so both parties reconstruct identical bytes. ASN.1's tag-length-value framing keeps field boundaries explicit, removing the concatenation ambiguity a flat `nonce || identity` message would allow (an extension attack). This binds the attestation to one specific request, session, and context, preventing identity-spoofing and replay where a compromised session substitutes a different party's identity bytes.
103+
**Nonce / Signature Binding.** Every `RecipientRequest` (and `ExchangeRecipientRequest`) carries a cryptographic nonce (`NonceSize` bytes) generated by the initiator. The responder signs an *attestation message* — a DER-encoded (`encoding/asn1`) structure that captures every field of the received request (TMSID, wallet id, identity, multisig flag, policy, nonce) together with the session id and the context id — using the private key corresponding to the returned identity (obtained via `tms.SigService().GetSigner`). The initiator rebuilds the same structure and verifies the signature with `tms.SigService().OwnerVerifier` **before** registering the identity. The exchange flow is mutual: `ExchangeRecipientRequest.Signature` proves that the initiator owns the recipient identity it asks the responder to register and bind. The session id and context id are propagated in the message header, so both parties reconstruct identical bytes. ASN.1's tag-length-value framing keeps field boundaries explicit, removing the concatenation ambiguity a flat nonce/identity message would allow. This binds the attestation to one specific request, session, and context, preventing identity-spoofing and replay where a compromised session substitutes a different party's identity bytes.
82104

83-
**Multisig.** When `RecipientRequest.MultiSig` is true, the initiator may send an additional `MultisigRecipientData` after the first exchange; the responder registers identities and updates bindings as in code. Each individual component identity is already attested through nonce/signature binding during the single-recipient phase.
105+
**Multisig and policy follow-ups.** When a composite identity was requested, the initiator sends `MultisigRecipientData` or `PolicyRecipientData` after the first exchange. Before changing local state, the responder checks that component identities, audit information, nodes, and recipients have equal cardinality; that ordered recipients match the composite components; that the responder's attested identity is included; and that a policy identity matches the policy requested in phase one.
84106

85107
#### `RequestRecipientIdentityView` / `RespondRequestRecipientIdentityView`
86108

@@ -144,12 +166,14 @@ sequenceDiagram
144166
rect rgba(230, 230, 250, 0.35)
145167
Note over I,R: Phase 1 - Exchange request (with nonce)
146168
I->>I: nonce = GetRandomNonce()
147-
I->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local), Nonce}
169+
I->>I: initiatorSig = Sign(attestation for RecipientData(local))
170+
I->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local), Nonce, Signature: initiatorSig}
148171
end
149172
150173
rect rgba(255, 245, 238, 0.5)
151174
Note over R: Phase 2 - Responder processing with attestation
152-
R->>R: Reject if Nonce is empty
175+
R->>R: Reject if Nonce, RecipientData, or initiator Signature is empty
176+
R->>R: Verify initiator attestation before state mutation or disclosure
153177
R->>R: RegisterRecipientIdentity(request.RecipientData)
154178
R->>R: recipientData = wallet.GetRecipientData()
155179
R->>R: msg = asn1(request fields + session id + context id + recipientData.Identity)
@@ -412,7 +436,7 @@ sequenceDiagram
412436
end
413437
```
414438

415-
`EndorseView` (`endorse.go`) is the responder for the signature-request leg; `AcceptView` (`accept.go`) responds to the transaction-distribution leg with a signed acknowledgement. `ReceiveTransactionView` (`receivetx.go`) unwraps the envelope and accepts `TypeTransaction`, `TypeTransactionResponse`, or `TypeSignatureRequest`.
439+
`EndorseView` (`endorse.go`) is the responder for the signature-request leg; before acknowledging the final distribution it requires the token actions, TMS identity, network transaction creator/nonce, signer, and transient data to match the transaction it reviewed. `AcceptView` (`accept.go`) responds to the transaction-distribution leg with a signed acknowledgement. `ReceiveTransactionView` (`receivetx.go`) unwraps the envelope and accepts `TypeTransaction`, `TypeTransactionResponse`, or `TypeSignatureRequest`. Transaction and transient ASN.1 decoders reject trailing data, duplicate transient keys, and key/value cardinality mismatches.
416440

417441
## Auditor Approval Flow
418442

@@ -501,7 +525,7 @@ Waiting is push-first: each waiter registers a status listener on the local data
501525

502526
### Transaction Recovery
503527

504-
Panurus includes an automatic recovery mechanism to handle pending transactions that may have lost their finality listeners due to node restarts, network interruptions, or other failures.
528+
Panurus includes an automatic recovery mechanism to handle pending transactions that may have lost their finality listeners due to node restarts, network interruptions, or other failures.
505529
The recovery service is part of the **Storage Service** and is instantiated by the **Network Service** to recover transactions from either `TTXDB` (for regular transactions) or `AuditDB` (for auditor nodes).
506530

507531
For detailed information about the recovery mechanism, see:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# TTX Responder Threat Model
2+
3+
This document defines the responder-side threat model for the interactive protocols under `../../../token/services/ttx`. Every session envelope, transaction byte string, recipient structure, and spend request received from a remote initiator is hostile.
4+
5+
## Security Goals
6+
7+
A responder must:
8+
9+
- reject malformed input with an error rather than panic;
10+
- release signatures or recipient data only after the protocol's authentication and consistency checks pass;
11+
- avoid mutating identity, endpoint, wallet, or transaction state before validating the structures that authorize the mutation;
12+
- bind acknowledgements to the transaction that the responder actually reviewed;
13+
- reject ambiguous encodings rather than accepting multiple byte strings for the same logical message.
14+
15+
The application still decides whether a valid transaction satisfies its business rules. In particular, the multisig and policy spend flows deliberately return the assembled transaction so that application code can verify that it consumes the token named in the earlier `SpendRequest` before calling `EndorseView`.
16+
17+
## Trust Boundaries
18+
19+
| Boundary | Hostile input | Security-sensitive operation |
20+
|----------|---------------|------------------------------|
21+
| `ReceiveTransactionView` | Envelope body and ASN.1 transaction bytes | TMS lookup, request validation, persistence, later signatures |
22+
| `EndorseView` | Signature request and final distributed transaction | Token-owner signature and node acknowledgement signature |
23+
| Recipient responders | TMS ID, wallet ID, recipient data, nonce, composite follow-up | Recipient/audit-data release, signer registration, endpoint binding |
24+
| Withdrawal and upgrade responders | Recipient data, token/proof material | Recipient registration and endpoint binding |
25+
| Multisig/policy spend responders | Serialized `SpendRequest` and assembled transaction | Application approval followed by token-owner endorsement |

token/services/ttx/boolpolicy/spend.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ func (f *ReceiveSpendRequestView) Call(context view.Context) (any, error) {
8080

8181
return nil, err
8282
}
83+
if tx.Token == nil {
84+
return nil, errors.New("invalid policy spend request: token is nil")
85+
}
8386

8487
return tx, nil
8588
}

token/services/ttx/boolpolicy/spend_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ func newSilentSession(t *testing.T) *dep_mock.Session {
110110
return s
111111
}
112112

113+
func TestReceiveSpendRequestViewRejectsNilToken(t *testing.T) {
114+
env, err := jsession.WrapEnvelope(&SpendRequest{}, ttx.TypeSpendRequest)
115+
require.NoError(t, err)
116+
raw, err := json.Marshal(env)
117+
require.NoError(t, err)
118+
119+
s := &dep_mock.Session{}
120+
messages := make(chan *view.Message, 1)
121+
messages <- &view.Message{Payload: raw}
122+
s.ReceiveReturns(messages)
123+
ctx := &dep_mock.Context{}
124+
ctx.ContextReturns(t.Context())
125+
ctx.SessionReturns(s)
126+
ctx.GetServiceReturns(nil, errors.New("service not registered in test"))
127+
128+
_, err = NewReceiveSpendRequestView().Call(ctx)
129+
require.Error(t, err)
130+
require.Contains(t, err.Error(), "token is nil")
131+
}
132+
113133
func TestRequestSpendView_Call_TimesOutOnUnresponsiveParty(t *testing.T) {
114134
partyA := view.Identity("party-a")
115135
partyB := view.Identity("party-b")

token/services/ttx/endorse.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,59 @@ func (s *EndorseView) receiveTransaction(context view.Context) ([]byte, error) {
154154
return nil, errors.Wrapf(err, "failed receiving transaction")
155155
}
156156

157-
// check that the content of the token request match
158-
m1, err := s.tx.TokenRequest.MarshalToSign()
157+
if err := validateEndorsedTransaction(s.tx, tx); err != nil {
158+
return nil, err
159+
}
160+
161+
return tx.FromRaw, nil
162+
}
163+
164+
func validateEndorsedTransaction(expected, received *Transaction) error {
165+
if expected == nil || expected.Payload == nil || received == nil || received.Payload == nil {
166+
return errors.New("invalid endorsed transaction: transaction or payload is nil")
167+
}
168+
if expected.TokenRequest == nil || received.TokenRequest == nil {
169+
return errors.New("invalid endorsed transaction: token request is nil")
170+
}
171+
m1, err := expected.TokenRequest.MarshalToSign()
159172
if err != nil {
160-
return nil, errors.Wrap(err, "failed to marshal token request to sign from the local transaction")
173+
return errors.Wrap(err, "failed to marshal token request to sign from the local transaction")
161174
}
162-
m2, err := tx.TokenRequest.MarshalToSign()
175+
m2, err := received.TokenRequest.MarshalToSign()
163176
if err != nil {
164-
return nil, errors.Wrap(err, "failed to marshal token request to sign from the remote transaction")
177+
return errors.Wrap(err, "failed to marshal token request to sign from the remote transaction")
165178
}
166179
if !bytes.Equal(m1, m2) {
167-
return nil, errors.Errorf("token request's signer does not match the expected signer")
180+
return errors.New("invalid endorsed transaction: token request changed")
181+
}
182+
if expected.ID() != received.ID() || !expected.TMSID().Equal(received.TMSID()) {
183+
return errors.New("invalid endorsed transaction: transaction identity changed")
184+
}
185+
if !bytes.Equal(expected.TxID.Nonce, received.TxID.Nonce) || !bytes.Equal(expected.TxID.Creator, received.TxID.Creator) {
186+
return errors.New("invalid endorsed transaction: network transaction identity changed")
187+
}
188+
if !bytes.Equal(expected.Signer, received.Signer) {
189+
return errors.New("invalid endorsed transaction: network signer changed")
190+
}
191+
if !equalTransient(expected.Transient, received.Transient) {
192+
return errors.New("invalid endorsed transaction: transient data changed")
168193
}
169194

170-
return tx.FromRaw, nil
195+
return nil
196+
}
197+
198+
func equalTransient(left, right map[string][]byte) bool {
199+
if len(left) != len(right) {
200+
return false
201+
}
202+
for key, value := range left {
203+
other, ok := right[key]
204+
if !ok || !bytes.Equal(value, other) {
205+
return false
206+
}
207+
}
208+
209+
return true
171210
}
172211

173212
// ack sends back an acknowledgement message to the initiator of the endorsement collection process.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package ttx
8+
9+
import (
10+
"testing"
11+
12+
"github.com/LFDT-Panurus/panurus/token"
13+
"github.com/LFDT-Panurus/panurus/token/services/network"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func endorsementTransaction() *Transaction {
19+
return &Transaction{Payload: &Payload{
20+
TxID: network.TxID{Nonce: []byte("nonce"), Creator: []byte("creator")},
21+
ID: "anchor",
22+
tmsID: token.TMSID{Network: "network", Channel: "channel", Namespace: "namespace"},
23+
Signer: []byte("signer"),
24+
Transient: network.TransientMap{"key": []byte("value")},
25+
TokenRequest: token.NewRequest(nil, "anchor"),
26+
}}
27+
}
28+
29+
func cloneEndorsementTransaction(tx *Transaction) *Transaction {
30+
payload := *tx.Payload
31+
payload.TxID.Nonce = append([]byte(nil), tx.TxID.Nonce...)
32+
payload.TxID.Creator = append([]byte(nil), tx.TxID.Creator...)
33+
payload.Signer = append([]byte(nil), tx.Signer...)
34+
payload.Transient = network.TransientMap{}
35+
for key, value := range tx.Transient {
36+
payload.Transient[key] = append([]byte(nil), value...)
37+
}
38+
39+
return &Transaction{Payload: &payload}
40+
}
41+
42+
func TestValidateEndorsedTransactionRejectsImmutableFieldSubstitution(t *testing.T) {
43+
expected := endorsementTransaction()
44+
require.NoError(t, validateEndorsedTransaction(expected, cloneEndorsementTransaction(expected)))
45+
46+
tests := []struct {
47+
name string
48+
mutate func(*Transaction)
49+
match string
50+
}{
51+
{name: "network signer", mutate: func(tx *Transaction) { tx.Signer = []byte("mallory") }, match: "network signer changed"},
52+
{name: "network creator", mutate: func(tx *Transaction) { tx.TxID.Creator = []byte("mallory") }, match: "network transaction identity changed"},
53+
{name: "network nonce", mutate: func(tx *Transaction) { tx.TxID.Nonce = []byte("mallory") }, match: "network transaction identity changed"},
54+
{name: "transient value", mutate: func(tx *Transaction) { tx.Transient["key"] = []byte("malicious") }, match: "transient data changed"},
55+
{name: "transient key", mutate: func(tx *Transaction) { tx.Transient["extra"] = []byte("malicious") }, match: "transient data changed"},
56+
{name: "tms", mutate: func(tx *Transaction) { tx.tmsID.Namespace = "other" }, match: "transaction identity changed"},
57+
}
58+
59+
for _, test := range tests {
60+
t.Run(test.name, func(t *testing.T) {
61+
received := cloneEndorsementTransaction(expected)
62+
test.mutate(received)
63+
err := validateEndorsedTransaction(expected, received)
64+
require.Error(t, err)
65+
assert.Contains(t, err.Error(), test.match)
66+
})
67+
}
68+
}

token/services/ttx/envelope_protocol_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ func TestVersionedRecipientResponseAckRoundTrip(t *testing.T) {
5151

5252
func TestVersionedExchangeRecipientRoundTrip(t *testing.T) {
5353
original := &ExchangeRecipientRequest{
54-
TMSID: token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"},
55-
WalletID: []byte("wallet"),
56-
Nonce: []byte("exchange-nonce-32bytes-pad-xxxxx"),
54+
TMSID: token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"},
55+
WalletID: []byte("wallet"),
56+
Nonce: []byte("exchange-nonce-32bytes-pad-xxxxx"),
57+
Signature: []byte("initiator-signature"),
5758
}
5859
roundTripTTXMessage(t, TypeExchangeRecipientRequest, original, &ExchangeRecipientRequest{})
5960
}

token/services/ttx/marshaller.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,25 @@ func MarshalMeta(v map[string][]byte) ([]byte, error) {
5454

5555
func UnmarshalMeta(raw []byte) (map[string][]byte, error) {
5656
var metaSer metaSer
57-
_, err := asn1.Unmarshal(raw, &metaSer)
57+
rest, err := asn1.Unmarshal(raw, &metaSer)
5858
if err != nil {
5959
return nil, err
6060
}
61+
if len(rest) != 0 {
62+
return nil, errors.Errorf("invalid transient metadata: trailing data [%d] bytes", len(rest))
63+
}
64+
if len(metaSer.Keys) != len(metaSer.Vals) {
65+
return nil, errors.Errorf(
66+
"invalid transient metadata: key/value count mismatch [%d]!=[%d]",
67+
len(metaSer.Keys),
68+
len(metaSer.Vals),
69+
)
70+
}
6171
v := make(map[string][]byte, len(metaSer.Keys))
6272
for i, k := range metaSer.Keys {
73+
if _, ok := v[k]; ok {
74+
return nil, errors.Errorf("invalid transient metadata: duplicate key [%s]", k)
75+
}
6376
v[k] = metaSer.Vals[i]
6477
}
6578

@@ -153,8 +166,12 @@ func marshal(t *Transaction, eIDs ...string) ([]byte, error) {
153166

154167
func unmarshal(getNetwork GetNetworkFunc, p *Payload, raw []byte) error {
155168
var ser TransactionSer
156-
if _, err := asn1.Unmarshal(raw, &ser); err != nil {
157-
return errors.Wrapf(err, "failed unmarshalling transaction [%s]", string(raw))
169+
rest, err := asn1.Unmarshal(raw, &ser)
170+
if err != nil {
171+
return errors.Wrap(err, "failed unmarshalling transaction")
172+
}
173+
if len(rest) != 0 {
174+
return errors.Errorf("failed unmarshalling transaction: trailing data [%d] bytes", len(rest))
158175
}
159176
// sanity checks
160177
if len(ser.Network) == 0 {

0 commit comments

Comments
 (0)