diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f86c0f..0bf0db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **TAIP-15 trust connections** (per the pending spec revision + [TAIPs#53](https://github.com/TransactionAuthorizationProtocol/TAIPs/pull/53)): + `ConnectBody` gains `ConnectionTypes` (`connectionTypes`) and `Action` + (`action`) fields, plus `ConnectionType*` and `ConnectAction*` constants. + `AuthorizeBody` gains `ApprovedTypes` (`approvedTypes`) for connection + approvals. + ### Changed +- `NewConnectMessage` validates `requester`/`principal`/`agents`/`constraints` + only for transactional connections (`connectionTypes` absent or containing + `"transaction"`). Trust connections (`ddq-access`, `mutual-trust`, + `whitelist`) omit them, and the four fields are now `omitempty` in JSON. - **BREAKING (TAIP-9):** Reshaped `ConfirmRelationshipBody` to match the TAIP-9 spec, whose confirmation payload is an `Agent` payload. The body is now flat: `@context`, `@type` (set to `https://tap.rsvp/schema/1.0#Agent`), `@id` (the diff --git a/authorize.go b/authorize.go index d68d7c2..0b027d9 100644 --- a/authorize.go +++ b/authorize.go @@ -10,12 +10,13 @@ import ( // AuthorizeBody represents the body of a TAP Authorize message (TAIP-4). type AuthorizeBody struct { - Context string `json:"@context"` - Type string `json:"@type"` - SettlementAddress string `json:"settlementAddress,omitempty"` - SettlementAsset string `json:"settlementAsset,omitempty"` - Amount string `json:"amount,omitempty"` - Expiry string `json:"expiry,omitempty"` + Context string `json:"@context"` + Type string `json:"@type"` + SettlementAddress string `json:"settlementAddress,omitempty"` + SettlementAsset string `json:"settlementAsset,omitempty"` + Amount string `json:"amount,omitempty"` + Expiry string `json:"expiry,omitempty"` + ApprovedTypes []string `json:"approvedTypes,omitempty"` } func (b *AuthorizeBody) TAPType() string { return TypeAuthorize } diff --git a/authorize_test.go b/authorize_test.go index 0739b1b..128fcdb 100644 --- a/authorize_test.go +++ b/authorize_test.go @@ -105,3 +105,62 @@ func TestAuthorizeBody_ParseBody(t *testing.T) { t.Errorf("SettlementAddress: got %q", ab.SettlementAddress) } } + +func TestAuthorizeBody_JSONRoundTrip_ApprovedTypes(t *testing.T) { + body := AuthorizeBody{ + Context: TAPContext, + Type: TypeAuthorize, + ApprovedTypes: []string{ConnectionTypeDDQAccess}, + } + + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got AuthorizeBody + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.ApprovedTypes) != 1 || got.ApprovedTypes[0] != ConnectionTypeDDQAccess { + t.Errorf("ApprovedTypes: got %v", got.ApprovedTypes) + } +} + +func TestAuthorizeBody_TransactionAuthorizeOmitsApprovedTypes(t *testing.T) { + msg, err := NewAuthorizeMessage("from", []string{"to"}, "thid", + &AuthorizeBody{SettlementAddress: "eip155:1:0x1234"}) + if err != nil { + t.Fatalf("create: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(msg.Body, &raw); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if _, ok := raw["approvedTypes"]; ok { + t.Error("approvedTypes must be omitted on a transaction Authorize") + } +} + +func TestAuthorize_TestVectorConnectionApproved(t *testing.T) { + data, err := os.ReadFile("TAIPs/test-vectors/connect/valid-authorize-approved.json") + if err != nil { + t.Skipf("test vector not available: %v", err) + } + + var tv struct { + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(data, &tv); err != nil { + t.Fatalf("unmarshal test vector: %v", err) + } + + var body AuthorizeBody + if err := json.Unmarshal(tv.Body, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if len(body.ApprovedTypes) == 0 || body.ApprovedTypes[0] != ConnectionTypeDDQAccess { + t.Errorf("ApprovedTypes: got %v", body.ApprovedTypes) + } +} diff --git a/connect.go b/connect.go index 9f9d293..36feb77 100644 --- a/connect.go +++ b/connect.go @@ -3,38 +3,59 @@ package tap import ( "encoding/json" "fmt" + "slices" didcomm "github.com/Notabene-id/go-didcomm" "github.com/google/uuid" ) +// Connection types carried in ConnectBody.ConnectionTypes (TAIP-15). +const ( + ConnectionTypeTransaction = "transaction" + ConnectionTypeDDQAccess = "ddq-access" + ConnectionTypeMutualTrust = "mutual-trust" + ConnectionTypeWhitelist = "whitelist" +) + +// Connection lifecycle actions carried in ConnectBody.Action (TAIP-15). +const ( + ConnectActionEstablish = "establish" + ConnectActionUpdate = "update" +) + // ConnectBody represents the body of a TAP Connect message (TAIP-15). type ConnectBody struct { - Context string `json:"@context"` - Type string `json:"@type"` - Requester *Party `json:"requester"` - Principal *Party `json:"principal"` - Agents []Agent `json:"agents"` - Constraints *TransactionConstraints `json:"constraints"` - Agreement string `json:"agreement,omitempty"` - Expiry string `json:"expiry,omitempty"` + Context string `json:"@context"` + Type string `json:"@type"` + ConnectionTypes []string `json:"connectionTypes,omitempty"` + Action string `json:"action,omitempty"` + Requester *Party `json:"requester,omitempty"` + Principal *Party `json:"principal,omitempty"` + Agents []Agent `json:"agents,omitempty"` + Constraints *TransactionConstraints `json:"constraints,omitempty"` + Agreement string `json:"agreement,omitempty"` + Expiry string `json:"expiry,omitempty"` } func (b *ConnectBody) TAPType() string { return TypeConnect } // NewConnectMessage creates a new DIDComm message with a Connect body. +// Requester, principal, agents, and constraints are validated for +// transactional connections only. func NewConnectMessage(from string, to []string, body *ConnectBody) (*didcomm.Message, error) { - if body.Requester == nil { - return nil, fmt.Errorf("%w: missing requester", ErrInvalidBody) - } - if body.Principal == nil { - return nil, fmt.Errorf("%w: missing principal", ErrInvalidBody) - } - if len(body.Agents) == 0 { - return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) - } - if body.Constraints == nil { - return nil, fmt.Errorf("%w: missing constraints", ErrInvalidBody) + if transactional(body.ConnectionTypes) { + if body.Requester == nil { + return nil, fmt.Errorf("%w: missing requester", ErrInvalidBody) + } + if body.Principal == nil { + return nil, fmt.Errorf("%w: missing principal", ErrInvalidBody) + } + if len(body.Agents) == 0 { + return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) + } + if body.Constraints == nil { + return nil, fmt.Errorf("%w: missing constraints", ErrInvalidBody) + } } body.Context = TAPContext @@ -53,3 +74,10 @@ func NewConnectMessage(from string, to []string, body *ConnectBody) (*didcomm.Me Body: rawBody, }, nil } + +// transactional reports whether the connection types describe a transactional +// connection. An empty list is a pre-revision Connect, which is always +// transactional. +func transactional(connectionTypes []string) bool { + return len(connectionTypes) == 0 || slices.Contains(connectionTypes, ConnectionTypeTransaction) +} diff --git a/connect_test.go b/connect_test.go index cffdea8..2d97dda 100644 --- a/connect_test.go +++ b/connect_test.go @@ -141,3 +141,87 @@ func TestConnectBody_ParseBody(t *testing.T) { t.Errorf("Requester.ID: got %q", cb.Requester.ID) } } + +func TestNewConnectMessage_TrustConnection(t *testing.T) { + body := &ConnectBody{ + ConnectionTypes: []string{ConnectionTypeDDQAccess}, + Action: ConnectActionEstablish, + } + msg, err := NewConnectMessage("did:web:req.example", []string{"did:web:owner.example"}, body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(msg.Body, &raw); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + for _, field := range []string{"requester", "principal", "agents", "constraints"} { + if _, ok := raw[field]; ok { + t.Errorf("%s must be omitted on a trust connection", field) + } + } + if string(raw["connectionTypes"]) != `["ddq-access"]` { + t.Errorf("connectionTypes: got %s", raw["connectionTypes"]) + } + if string(raw["action"]) != `"establish"` { + t.Errorf("action: got %s", raw["action"]) + } +} + +func TestNewConnectMessage_TransactionalTypeRequiresFields(t *testing.T) { + body := &ConnectBody{ + ConnectionTypes: []string{ConnectionTypeTransaction}, + } + _, err := NewConnectMessage("from", nil, body) + if !errors.Is(err, ErrInvalidBody) { + t.Errorf("expected ErrInvalidBody, got %v", err) + } +} + +func TestConnectBody_JSONRoundTrip_TrustFields(t *testing.T) { + body := ConnectBody{ + Context: TAPContext, + Type: TypeConnect, + ConnectionTypes: []string{ConnectionTypeDDQAccess, ConnectionTypeMutualTrust}, + Action: ConnectActionUpdate, + } + + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got ConnectBody + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.ConnectionTypes) != 2 || got.ConnectionTypes[0] != ConnectionTypeDDQAccess { + t.Errorf("ConnectionTypes: got %v", got.ConnectionTypes) + } + if got.Action != ConnectActionUpdate { + t.Errorf("Action: got %q", got.Action) + } +} + +func TestConnect_TestVectorEstablishDDQ(t *testing.T) { + data, err := os.ReadFile("TAIPs/test-vectors/connect/valid-establish-ddq.json") + if err != nil { + t.Skipf("test vector not available: %v", err) + } + + var tv struct { + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(data, &tv); err != nil { + t.Fatalf("unmarshal test vector: %v", err) + } + + var body ConnectBody + if err := json.Unmarshal(tv.Body, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if len(body.ConnectionTypes) == 0 || body.ConnectionTypes[0] != ConnectionTypeDDQAccess { + t.Errorf("ConnectionTypes: got %v", body.ConnectionTypes) + } +}