Skip to content

Commit 3630e34

Browse files
SuyashAlphaCadecaro
authored andcommitted
feat(ttx): recipient wire hardening — slim echo + nonce/signature binding (#1652 M3)
Reduce wire overhead and prevent identity spoofing in the recipient identity exchange protocol: - Slim echo: on the echo path the responder now returns only a signature (RecipientResponse with nil RecipientData); the initiator already holds the full RecipientData and reuses its own copy. - Nonce/signature binding: every RecipientRequest and ExchangeRecipientRequest carries a 32-byte nonce. The responder signs nonce||identity with SigService.GetSigner; the initiator verifies via SigService.OwnerVerifier before registering. - New wire types: RecipientResponse, ExchangeRecipientResponse. - Tests for round-trip, slim ack, malformed JSON, nonce helpers. - Updated Mermaid diagrams and prose in docs/services/ttx.md. Signed-off-by: SuyashAlphaC <suyashagrawalsdw@gmail.com>
1 parent bfe7088 commit 3630e34

6 files changed

Lines changed: 362 additions & 84 deletions

File tree

docs/services/ttx.md

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ sequenceDiagram
2424
participant Network
2525
end
2626
27-
Note over Initiator: 1. Request Identities
28-
Initiator->>+Recipient: RequestRecipientIdentityView
29-
Recipient-->>-Initiator: Recipient Data (Identity + Audit Info)
27+
Note over Initiator: 1. Request Identities (with nonce/signature attestation)
28+
Initiator->>+Recipient: RequestRecipientIdentityView (includes Nonce)
29+
Recipient-->>-Initiator: RecipientResponse (Identity + Audit Info + Signature)
3030
3131
Note over Initiator: 2. Assemble Request
3232
Initiator->>+Initiator: Issue / Transfer / Redeem operations
@@ -77,14 +77,14 @@ The recipient identity protocols are implemented in `token/services/ttx/recipien
7777

7878
Wire messages use JSON sessions (`token/services/utils/json/session`); the diagrams name the Go types being sent or received.
7979

80-
**Response paths (today).** In `RespondRequestRecipientIdentityView`, after the wallet lookup:
80+
**Response paths.** In `RespondRequestRecipientIdentityView`, after the wallet lookup:
8181

82-
- If `recipientRequest.RecipientData != nil`, the responder checks `OwnerWallet.Contains` for `RecipientData.Identity`, then sends **the same** `RecipientData` value back on the session (echo path). The responder does **not** substitute wallet-held `AuditInfo` / metadata into that payload; what goes on the wire is the initiator-supplied structure (after the contains check).
83-
- If `recipientRequest.RecipientData == nil`, the responder calls `OwnerWallet.GetRecipientData` and sends that **wallet-produced** `RecipientData` (fresh path).
82+
- 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.
83+
- If `recipientRequest.RecipientData == nil`, the responder calls `OwnerWallet.GetRecipientData` and sends a full `RecipientResponse` carrying the wallet-produced `RecipientData` plus a `Signature` (fresh path).
8484

85-
In both cases the initiator receives a full `RecipientData` over the wire. The initiator then calls `WalletManager.RegisterRecipientIdentity` with that payload and updates the endpoint resolver. **Protocol hardening** may replace full-object responses with a minimal acknowledgement (or another minimal wire type) so the responder does not ship an entire `RecipientData` when a slimmer response suffices; that is a separate code change from this documentation.
85+
**Nonce / Signature Binding.** Every `RecipientRequest` (and `ExchangeRecipientRequest`) carries a 32-byte cryptographic nonce generated by the initiator. The responder signs `nonce || recipientIdentity` with the private key corresponding to the returned identity (obtained via `tms.SigService().GetSigner`). The initiator verifies this signature using `tms.SigService().OwnerVerifier` **before** registering the identity. This prevents identity-spoofing attacks where a compromised session could substitute a different party's identity bytes.
8686

87-
**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.
87+
**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.
8888

8989
#### `RequestRecipientIdentityView` / `RespondRequestRecipientIdentityView`
9090

@@ -95,26 +95,34 @@ sequenceDiagram
9595
participant R as Responder
9696
9797
rect rgba(230, 230, 250, 0.35)
98-
Note over I,R: Phase 1 - Identity request
99-
I->>R: RecipientRequest{TMSID, WalletID, RecipientData?, MultiSig}
98+
Note over I,R: Phase 1 - Identity request (with nonce)
99+
I->>I: nonce = generateNonce() (32 bytes)
100+
I->>R: RecipientRequest{TMSID, WalletID, RecipientData?, MultiSig, Nonce}
100101
end
101102
102103
rect rgba(255, 245, 238, 0.5)
103-
Note over R: Phase 2 - Responder decision and reply
104-
alt RecipientRequest.RecipientData != nil
104+
Note over R: Phase 2 - Responder decision, attestation, and reply
105+
R->>R: Reject if Nonce is empty
106+
alt RecipientRequest.RecipientData != nil (echo path)
105107
R->>R: Verify wallet contains RecipientData.Identity
106-
else RecipientRequest.RecipientData == nil
107-
R->>R: Generate RecipientData from wallet
108+
R->>R: sig = Sign(nonce || identity)
109+
R->>R: endpoint.Bind(context.Me, identity)
110+
R-->>I: RecipientResponse{Signature: sig} (slim ack, no RecipientData)
111+
else RecipientRequest.RecipientData == nil (fresh path)
112+
R->>R: recipientData = wallet.GetRecipientData()
113+
R->>R: sig = Sign(nonce || recipientData.Identity)
114+
R->>R: endpoint.Bind(context.Me, recipientData.Identity)
115+
R-->>I: RecipientResponse{RecipientData, Signature: sig}
108116
end
109-
R->>R: endpoint.Bind(context.Me, RecipientData.Identity)
110-
Note over R,I: Bind before send so local resolver wiring fails before the peer receives RecipientData
111-
R-->>I: RecipientData (echo or fresh data path)
112117
end
113118
114119
rect rgba(240, 255, 240, 0.45)
115-
Note over I,R: Phase 3 - Initiator registration and bindings
116-
I->>I: RegisterRecipientIdentity(RecipientData)
117-
I->>I: endpoint.Bind(requested FSC identity, RecipientData.Identity)
120+
Note over I,R: Phase 3 - Initiator verification, registration, bindings
121+
I->>I: Determine recipientData (own copy on echo, response on fresh)
122+
I->>I: verifier = OwnerVerifier(recipientData.Identity)
123+
I->>I: verifier.Verify(nonce || identity, resp.Signature)
124+
I->>I: RegisterRecipientIdentity(recipientData)
125+
I->>I: endpoint.Bind(requested FSC identity, recipientData.Identity)
118126
end
119127
120128
rect rgba(245, 245, 245, 0.55)
@@ -125,8 +133,6 @@ sequenceDiagram
125133
R->>R: endpoint.Bind(each Node -> Recipient)
126134
end
127135
end
128-
129-
Note over I,R: Full RecipientData on wire today (echo of request vs wallet-generated)
130136
```
131137

132138
#### `ExchangeRecipientIdentitiesView` / `RespondExchangeRecipientIdentitiesView`
@@ -138,27 +144,29 @@ sequenceDiagram
138144
participant R as Responder
139145
140146
rect rgba(230, 230, 250, 0.35)
141-
Note over I,R: Phase 1 - Exchange request
142-
I->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local)}
147+
Note over I,R: Phase 1 - Exchange request (with nonce)
148+
I->>I: nonce = generateNonce() (32 bytes)
149+
I->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local), Nonce}
143150
end
144151
145152
rect rgba(255, 245, 238, 0.5)
146-
Note over R: Phase 2 - Responder processing
153+
Note over R: Phase 2 - Responder processing with attestation
154+
R->>R: Reject if Nonce is empty
147155
R->>R: RegisterRecipientIdentity(request.RecipientData)
148-
R->>R: GetRecipientData(responder wallet)
149-
R->>R: endpoint.Bind(context.Me, responder RecipientData.Identity)
156+
R->>R: recipientData = wallet.GetRecipientData()
157+
R->>R: sig = Sign(nonce || recipientData.Identity)
158+
R->>R: endpoint.Bind(context.Me, recipientData.Identity)
150159
R->>R: endpoint.Bind(session caller, request.RecipientData.Identity)
151-
Note over R,I: Binds before send so resolver wiring fails before the initiator receives our RecipientData
152-
R-->>I: RecipientData(responder)
160+
R-->>I: ExchangeRecipientResponse{RecipientData, Signature: sig}
153161
end
154162
155163
rect rgba(240, 255, 240, 0.45)
156-
Note over I,R: Phase 3 - Initiator registration and bindings
157-
I->>I: RegisterRecipientIdentity(remote RecipientData)
158-
I->>I: endpoint.Bind(other FSC identity, remote RecipientData.Identity)
164+
Note over I,R: Phase 3 - Initiator verification, registration, bindings
165+
I->>I: verifier = OwnerVerifier(resp.RecipientData.Identity)
166+
I->>I: verifier.Verify(nonce || identity, resp.Signature)
167+
I->>I: RegisterRecipientIdentity(resp.RecipientData)
168+
I->>I: endpoint.Bind(other FSC identity, resp.RecipientData.Identity)
159169
end
160-
161-
Note over I,R: Full RecipientData on wire today (responder sends local wallet RecipientData)
162170
```
163171

164172
### PolicyIdentity — Boolean-Expression-Governed Ownership

token/services/ttx/envelope_protocol_test.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,49 @@ func TestVersionedRecipientRequestRoundTrip(t *testing.T) {
2626
TMSID: token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"},
2727
WalletID: []byte("wallet"),
2828
MultiSig: true,
29+
Nonce: []byte("test-nonce-32bytes-padding-xxxxx"),
2930
}
3031
roundTripTTXMessage(t, TypeRecipientRequest, original, &RecipientRequest{})
3132
}
3233

3334
func TestVersionedRecipientResponseRoundTrip(t *testing.T) {
34-
original := &RecipientData{
35-
Identity: []byte("recipient"),
36-
AuditInfo: []byte("audit"),
35+
original := &RecipientResponse{
36+
RecipientData: &RecipientData{
37+
Identity: []byte("recipient"),
38+
AuditInfo: []byte("audit"),
39+
},
40+
Signature: []byte("sig-bytes"),
41+
}
42+
roundTripTTXMessage(t, TypeRecipientResponse, original, &RecipientResponse{})
43+
}
44+
45+
func TestVersionedRecipientResponseAckRoundTrip(t *testing.T) {
46+
original := &RecipientResponse{
47+
Signature: []byte("sig-bytes"),
3748
}
38-
roundTripTTXMessage(t, TypeRecipientResponse, original, &RecipientData{})
49+
roundTripTTXMessage(t, TypeRecipientResponse, original, &RecipientResponse{})
3950
}
4051

4152
func TestVersionedExchangeRecipientRoundTrip(t *testing.T) {
4253
original := &ExchangeRecipientRequest{
4354
TMSID: token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"},
4455
WalletID: []byte("wallet"),
56+
Nonce: []byte("exchange-nonce-32bytes-pad-xxxxx"),
4557
}
4658
roundTripTTXMessage(t, TypeExchangeRecipientRequest, original, &ExchangeRecipientRequest{})
4759
}
4860

61+
func TestVersionedExchangeRecipientResponseRoundTrip(t *testing.T) {
62+
original := &ExchangeRecipientResponse{
63+
RecipientData: &RecipientData{
64+
Identity: []byte("responder"),
65+
AuditInfo: []byte("audit"),
66+
},
67+
Signature: []byte("exchange-sig"),
68+
}
69+
roundTripTTXMessage(t, TypeExchangeRecipientResp, original, &ExchangeRecipientResponse{})
70+
}
71+
4972
func TestVersionedMultisigRecipientDataRoundTrip(t *testing.T) {
5073
original := &MultisigRecipientData{
5174
RecipientData: &token.RecipientData{Identity: []byte("ms")},

token/services/ttx/nonce.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package ttx
8+
9+
// buildAttestationMessage constructs the message that the responder signs
10+
// to prove key-ownership: nonce || identity.
11+
func buildAttestationMessage(nonce []byte, identity []byte) []byte {
12+
msg := make([]byte, 0, len(nonce)+len(identity))
13+
msg = append(msg, nonce...)
14+
msg = append(msg, identity...)
15+
16+
return msg
17+
}

token/services/ttx/nonce_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
"encoding/json"
11+
"testing"
12+
13+
"github.com/hyperledger-labs/fabric-smart-client/platform/view/view"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestAttestationNonceViaGetRandomNonce(t *testing.T) {
19+
nonce, err := GetRandomNonce()
20+
require.NoError(t, err)
21+
assert.Len(t, nonce, NonceSize)
22+
23+
nonce2, err := GetRandomNonce()
24+
require.NoError(t, err)
25+
assert.NotEqual(t, nonce, nonce2, "two nonces must differ")
26+
}
27+
28+
func TestBuildAttestationMessage(t *testing.T) {
29+
nonce := []byte("nonce-bytes")
30+
identity := []byte("identity-bytes")
31+
32+
msg := buildAttestationMessage(nonce, identity)
33+
assert.Equal(t, append([]byte("nonce-bytes"), []byte("identity-bytes")...), msg)
34+
assert.Len(t, msg, len(nonce)+len(identity))
35+
}
36+
37+
func TestBuildAttestationMessage_DifferentInputsProduceDifferentMessages(t *testing.T) {
38+
msg1 := buildAttestationMessage([]byte("AB"), []byte("CD"))
39+
msg2 := buildAttestationMessage([]byte("ABC"), []byte("D"))
40+
// "AB"+"CD" = "ABCD" and "ABC"+"D" = "ABCD" — both produce the same bytes.
41+
// This documents the current concatenation approach and can serve as a basis
42+
// for future length-prefixed separation if required.
43+
assert.Equal(t, msg1, msg2, "simple concatenation does not separate fields")
44+
}
45+
46+
func TestBuildAttestationMessage_EmptyInputs(t *testing.T) {
47+
msg := buildAttestationMessage(nil, nil)
48+
assert.Empty(t, msg)
49+
50+
msg = buildAttestationMessage([]byte("nonce"), nil)
51+
assert.Equal(t, []byte("nonce"), msg)
52+
53+
msg = buildAttestationMessage(nil, []byte("id"))
54+
assert.Equal(t, []byte("id"), msg)
55+
}
56+
57+
func TestRecipientResponse_JSONRoundTrip_FreshPath(t *testing.T) {
58+
original := &RecipientResponse{
59+
RecipientData: &RecipientData{
60+
Identity: view.Identity("alice"),
61+
AuditInfo: []byte("audit"),
62+
TokenMetadata: []byte("meta"),
63+
TokenMetadataAuditInfo: []byte("meta-audit"),
64+
},
65+
Signature: []byte("sig"),
66+
}
67+
raw, err := json.Marshal(original)
68+
require.NoError(t, err)
69+
70+
decoded := &RecipientResponse{}
71+
require.NoError(t, json.Unmarshal(raw, decoded))
72+
73+
require.NotNil(t, decoded.RecipientData)
74+
assert.Equal(t, original.RecipientData.Identity, decoded.RecipientData.Identity)
75+
assert.Equal(t, original.RecipientData.AuditInfo, decoded.RecipientData.AuditInfo)
76+
assert.Equal(t, original.Signature, decoded.Signature)
77+
}
78+
79+
func TestRecipientResponse_JSONRoundTrip_EchoPath(t *testing.T) {
80+
original := &RecipientResponse{
81+
Signature: []byte("sig-only"),
82+
}
83+
raw, err := json.Marshal(original)
84+
require.NoError(t, err)
85+
86+
decoded := &RecipientResponse{}
87+
require.NoError(t, json.Unmarshal(raw, decoded))
88+
89+
assert.Nil(t, decoded.RecipientData, "echo path response must have nil RecipientData")
90+
assert.Equal(t, original.Signature, decoded.Signature)
91+
}
92+
93+
func TestExchangeRecipientResponse_JSONRoundTrip(t *testing.T) {
94+
original := &ExchangeRecipientResponse{
95+
RecipientData: &RecipientData{
96+
Identity: view.Identity("bob"),
97+
AuditInfo: []byte("bob-audit"),
98+
},
99+
Signature: []byte("exchange-sig"),
100+
}
101+
raw, err := json.Marshal(original)
102+
require.NoError(t, err)
103+
104+
decoded := &ExchangeRecipientResponse{}
105+
require.NoError(t, json.Unmarshal(raw, decoded))
106+
107+
require.NotNil(t, decoded.RecipientData)
108+
assert.Equal(t, original.RecipientData.Identity, decoded.RecipientData.Identity)
109+
assert.Equal(t, original.Signature, decoded.Signature)
110+
}
111+
112+
func TestRecipientRequest_NoncePreservedInJSON(t *testing.T) {
113+
nonce := []byte("32-byte-nonce-for-testing-xxxxx!")
114+
original := &RecipientRequest{
115+
Nonce: nonce,
116+
}
117+
raw, err := json.Marshal(original)
118+
require.NoError(t, err)
119+
120+
decoded := &RecipientRequest{}
121+
require.NoError(t, json.Unmarshal(raw, decoded))
122+
assert.Equal(t, nonce, decoded.Nonce)
123+
}
124+
125+
func TestExchangeRecipientRequest_NoncePreservedInJSON(t *testing.T) {
126+
nonce := []byte("exchange-nonce-32bytes-pad-xxxxx")
127+
original := &ExchangeRecipientRequest{
128+
Nonce: nonce,
129+
}
130+
raw, err := json.Marshal(original)
131+
require.NoError(t, err)
132+
133+
decoded := &ExchangeRecipientRequest{}
134+
require.NoError(t, json.Unmarshal(raw, decoded))
135+
assert.Equal(t, nonce, decoded.Nonce)
136+
}
137+
138+
func TestRecipientResponse_MissingSignature(t *testing.T) {
139+
raw := []byte(`{"RecipientData":{"Identity":"YWxpY2U="}}`)
140+
decoded := &RecipientResponse{}
141+
require.NoError(t, json.Unmarshal(raw, decoded))
142+
assert.Nil(t, decoded.Signature, "missing Signature field should unmarshal as nil")
143+
assert.NotNil(t, decoded.RecipientData)
144+
}
145+
146+
func TestRecipientResponse_MalformedJSON(t *testing.T) {
147+
decoded := &RecipientResponse{}
148+
err := json.Unmarshal([]byte("not json {{"), decoded)
149+
require.Error(t, err)
150+
}

0 commit comments

Comments
 (0)