Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
59 changes: 59 additions & 0 deletions authorize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
66 changes: 47 additions & 19 deletions connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
84 changes: 84 additions & 0 deletions connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}