From d9781cf831604dd7de100e2a9489ca76d1ab4c38 Mon Sep 17 00:00:00 2001 From: AkramBitar Date: Wed, 17 Jun 2026 22:26:28 +0000 Subject: [PATCH] Add comprehensive validation and ownership proof verification for recipient identity registration Signed-off-by: AkramBitar --- token/services/identity/wallet/service.go | 31 ++ .../services/identity/wallet/service_test.go | 48 +- token/services/identity/wallet/validation.go | 168 ++++++ .../identity/wallet/validation_test.go | 499 ++++++++++++++++++ 4 files changed, 738 insertions(+), 8 deletions(-) create mode 100644 token/services/identity/wallet/validation.go create mode 100644 token/services/identity/wallet/validation_test.go diff --git a/token/services/identity/wallet/service.go b/token/services/identity/wallet/service.go index b21fe99ac8..67cd9d06e8 100644 --- a/token/services/identity/wallet/service.go +++ b/token/services/identity/wallet/service.go @@ -114,6 +114,37 @@ func (s *Service) RegisterRecipientIdentity(ctx context.Context, data *tdriver.R s.Logger.DebugfContext(ctx, "register recipient identity [%s] with audit info [%s]", data.Identity, utils.Hashable(data.AuditInfo)) + // Step 1: Validate basic structure (nil checks, empty checks, length bounds) + if err := validateBasicStructure(data); err != nil { + return errors.Wrap(err, "basic structure validation failed") + } + + // Step 2: Validate JSON structure + if err := validateJSONStructure(data.AuditInfo); err != nil { + return errors.Wrap(err, "JSON structure validation failed") + } + + // Step 3: Decode the typed identity once and validate its structure. + typedID, err := identity.UnmarshalTypedIdentity(data.Identity) + if err != nil { + return errors.Wrap(err, "failed to unmarshal typed identity") + } + if err := validateIdentityStructure(typedID); err != nil { + return errors.Wrap(err, "identity structure validation failed") + } + + // Step 4: Validate enrollment ID and revocation handle for non-composite types. + // Composite types (MultiSig, HTLC, Policy) have no enrollment ID or revocation handle. + if !isCompositeIdentityType(typedID.Type) { + if err := s.validateEnrollmentID(ctx, data); err != nil { + return errors.Wrap(err, "enrollment ID validation failed") + } + if err := s.validateRevocationHandle(ctx, data); err != nil { + return errors.Wrap(err, "revocation handle validation failed") + } + } + + // Step 5: Match identity against audit info (cryptographic binding) if err := s.Deserializer.MatchIdentity(ctx, data.Identity, data.AuditInfo); err != nil { return errors.Wrapf(err, "failed to match identity to audit information for [%s]:[%s]", data.Identity, utils.Hashable(data.AuditInfo)) } diff --git a/token/services/identity/wallet/service_test.go b/token/services/identity/wallet/service_test.go index 4f1f79b1c3..540a5a816d 100644 --- a/token/services/identity/wallet/service_test.go +++ b/token/services/identity/wallet/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/driver" dmock "github.com/LFDT-Panurus/panurus/token/driver/mock" + "github.com/LFDT-Panurus/panurus/token/services/identity" idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver" "github.com/LFDT-Panurus/panurus/token/services/identity/wallet" wmock "github.com/LFDT-Panurus/panurus/token/services/identity/wallet/mock" @@ -84,35 +85,45 @@ func TestRegisterRecipientIdentityFailuresAndSuccess(t *testing.T) { d := &dmock.Deserializer{} regSvc := wallet.NewService(&logging.MockLogger{}, ip, d, nil) + // Create a properly typed identity + typedID, err := identity.WrapWithType(driver.X509IdentityType, []byte("raw-identity")) + require.NoError(t, err) + // nil data - err := regSvc.RegisterRecipientIdentity(ctx, nil) + err = regSvc.RegisterRecipientIdentity(ctx, nil) require.Error(t, err) // RegisterRecipientIdentity fails + mockVerifier := &dmock.Verifier{} + mockVerifier.VerifyReturns(nil) + d.GetOwnerVerifierReturns(mockVerifier, nil) + d.MatchIdentityReturns(nil) + ip.GetEnrollmentIDReturns("alice", nil) + ip.GetRevocationHandlerReturns("rh", nil) ip.RegisterRecipientIdentityReturns(errors.New("rri")) - err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.Error(t, err) ip.RegisterRecipientIdentityReturns(nil) // MatchIdentity fails d.MatchIdentityReturns(errors.New("mismatch")) - err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id"), AuditInfo: []byte("ai")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.Error(t, err) d.MatchIdentityReturns(nil) // RegisterRecipientData fails ip.RegisterRecipientDataReturns(errors.New("rrd")) - err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id"), AuditInfo: []byte("ai")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.Error(t, err) ip.RegisterRecipientDataReturns(nil) // success ip.RegisterRecipientIdentityCalls(func(context.Context, driver.Identity) error { return nil }) d.MatchIdentityCalls(func(context.Context, driver.Identity, []byte) error { return nil }) - d.GetOwnerVerifierCalls(func(context.Context, driver.Identity) (driver.Verifier, error) { return &dmock.Verifier{}, nil }) + d.GetOwnerVerifierCalls(func(context.Context, driver.Identity) (driver.Verifier, error) { return mockVerifier, nil }) ip.RegisterRecipientDataCalls(func(context.Context, *driver.RecipientData) error { return nil }) - err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id"), AuditInfo: []byte("ai")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.NoError(t, err) } @@ -128,12 +139,22 @@ func (r *ipWithRollback) RollbackPartialRecipientRegistration(ctx context.Contex func TestRegisterRecipientIdentity_MatchIdentityFailureSkipsIdentityProvider(t *testing.T) { ctx := t.Context() + + // Create a properly typed identity + typedID, err := identity.WrapWithType(driver.X509IdentityType, []byte("raw-identity")) + require.NoError(t, err) + ip := &dmock.IdentityProvider{} + ip.GetEnrollmentIDReturns("alice", nil) + ip.GetRevocationHandlerReturns("rh", nil) d := &dmock.Deserializer{} + mockVerifier := &dmock.Verifier{} + mockVerifier.VerifyReturns(nil) + d.GetOwnerVerifierReturns(mockVerifier, nil) d.MatchIdentityReturns(errors.New("mismatch")) regSvc := wallet.NewService(&logging.MockLogger{}, ip, d, nil) - err := regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id"), AuditInfo: []byte("ai")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.Error(t, err) require.Zero(t, ip.RegisterRecipientIdentityCallCount()) require.Equal(t, 1, d.MatchIdentityCallCount()) @@ -141,14 +162,25 @@ func TestRegisterRecipientIdentity_MatchIdentityFailureSkipsIdentityProvider(t * func TestRegisterRecipientIdentity_RollbackWhenRegisterRecipientDataFails(t *testing.T) { ctx := t.Context() + + // Create a properly typed identity + typedID, err := identity.WrapWithType(driver.X509IdentityType, []byte("raw-identity")) + require.NoError(t, err) + base := &dmock.IdentityProvider{} + base.GetEnrollmentIDReturns("alice", nil) + base.GetRevocationHandlerReturns("rh", nil) base.RegisterRecipientIdentityReturns(nil) base.RegisterRecipientDataReturns(errors.New("rrd")) ip := &ipWithRollback{IdentityProvider: base} d := &dmock.Deserializer{} + mockVerifier := &dmock.Verifier{} + mockVerifier.VerifyReturns(nil) + d.GetOwnerVerifierReturns(mockVerifier, nil) + d.MatchIdentityReturns(nil) regSvc := wallet.NewService(&logging.MockLogger{}, ip, d, nil) - err := regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: driver.Identity("id"), AuditInfo: []byte("ai")}) + err = regSvc.RegisterRecipientIdentity(ctx, &driver.RecipientData{Identity: typedID, AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`)}) require.Error(t, err) require.Equal(t, 1, ip.rollbackCalls) } diff --git a/token/services/identity/wallet/validation.go b/token/services/identity/wallet/validation.go new file mode 100644 index 0000000000..f69a592f6a --- /dev/null +++ b/token/services/identity/wallet/validation.go @@ -0,0 +1,168 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package wallet + +import ( + "context" + "encoding/json" + + tdriver "github.com/LFDT-Panurus/panurus/token/driver" + "github.com/LFDT-Panurus/panurus/token/services/identity" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" +) + +// Validation errors +var ( + ErrEmptyIdentity = errors.New("empty identity") + ErrEmptyAuditInfo = errors.New("empty audit info") + ErrIdentityTooShort = errors.New("identity too short") + ErrIdentityTooLarge = errors.New("identity too large") + ErrEmptyRawIdentity = errors.New("empty raw identity data") + ErrAuditInfoTooLarge = errors.New("audit info too large") + ErrInvalidJSON = errors.New("audit info is not valid JSON") + ErrEmptyEnrollmentID = errors.New("empty enrollment ID") + ErrEnrollmentIDTooLong = errors.New("enrollment ID too long") + ErrEmptyRevocationHandle = errors.New("empty revocation handle") + ErrRevocationHandleTooLong = errors.New("revocation handle too long") +) + +const ( + // MaxEnrollmentIDLength defines the maximum allowed length for enrollment IDs + MaxEnrollmentIDLength = 256 + // MaxRevocationHandleLength defines the maximum allowed length for revocation handles + MaxRevocationHandleLength = 512 + // MinIdentityLength defines the minimum allowed length for identity data + MinIdentityLength = 10 + // MaxIdentityLength defines the maximum allowed length for identity data (prevents DoS). + // Set generously so legitimate identities are never rejected: composite identities + // (MultiSig, Policy) aggregate several inner identities and X509 chains / Idemix proofs + // can run to several KB. The bound exists only to reject pathological multi-MB blobs. + MaxIdentityLength = 1 << 20 // 1 MiB + // MaxAuditInfoLength defines the maximum allowed length for audit info (prevents DoS) + MaxAuditInfoLength = 50000 +) + +// validateBasicStructure performs nil and empty checks on RecipientData +func validateBasicStructure(data *tdriver.RecipientData) error { + if data == nil { + return ErrNilRecipientData + } + if len(data.Identity) == 0 { + return ErrEmptyIdentity + } + if len(data.Identity) < MinIdentityLength { + return errors.Wrapf(ErrIdentityTooShort, "identity is %d bytes (min %d)", len(data.Identity), MinIdentityLength) + } + if len(data.Identity) > MaxIdentityLength { + return errors.Wrapf(ErrIdentityTooLarge, "identity is %d bytes (max %d)", len(data.Identity), MaxIdentityLength) + } + if len(data.AuditInfo) == 0 { + return ErrEmptyAuditInfo + } + if len(data.AuditInfo) > MaxAuditInfoLength { + return errors.Wrapf(ErrAuditInfoTooLarge, "audit info is %d bytes (max %d)", len(data.AuditInfo), MaxAuditInfoLength) + } + + return nil +} + +// validateJSONStructure validates that audit info is valid JSON +func validateJSONStructure(auditInfo []byte) error { + if !json.Valid(auditInfo) { + return ErrInvalidJSON + } + + return nil +} + +// validateIdentityStructure validates the decoded typed identity: the type must be +// recognized and the raw identity payload must not be empty. +func validateIdentityStructure(typedID *identity.TypedIdentity) error { + // Validate the identity type is recognized + if !isValidIdentityType(typedID.Type) { + return errors.Errorf("unknown identity type: %d", typedID.Type) + } + + // Validate raw identity data is not empty + if len(typedID.Identity) == 0 { + return ErrEmptyRawIdentity + } + + return nil +} + +// validateEnrollmentID validates the enrollment ID is present and within length bounds. +// The caller is responsible for skipping composite identity types that have no enrollment ID. +func (s *Service) validateEnrollmentID(ctx context.Context, data *tdriver.RecipientData) error { + // Extract enrollment ID + eid, err := s.IdentityProvider.GetEnrollmentID(ctx, data.Identity, data.AuditInfo) + if err != nil { + return errors.Wrap(err, "failed to extract enrollment ID") + } + + // Check not empty + if len(eid) == 0 { + return ErrEmptyEnrollmentID + } + + // Check length + if len(eid) > MaxEnrollmentIDLength { + return errors.Wrapf(ErrEnrollmentIDTooLong, "enrollment ID is %d bytes (max %d)", len(eid), MaxEnrollmentIDLength) + } + + return nil +} + +// validateRevocationHandle validates the revocation handle is present and within length bounds. +// The caller is responsible for skipping composite identity types that have no revocation handle. +func (s *Service) validateRevocationHandle(ctx context.Context, data *tdriver.RecipientData) error { + // Extract revocation handle + rh, err := s.IdentityProvider.GetRevocationHandler(ctx, data.Identity, data.AuditInfo) + if err != nil { + return errors.Wrap(err, "failed to extract revocation handle") + } + + // Check not empty + if len(rh) == 0 { + return ErrEmptyRevocationHandle + } + + // Check reasonable length + if len(rh) > MaxRevocationHandleLength { + return errors.Wrapf(ErrRevocationHandleTooLong, "revocation handle is %d bytes (max %d)", len(rh), MaxRevocationHandleLength) + } + + return nil +} + +// isValidIdentityType checks if the identity type is one of the recognized types +func isValidIdentityType(t tdriver.IdentityType) bool { + switch t { + case tdriver.IdemixIdentityType, + tdriver.X509IdentityType, + tdriver.IdemixNymIdentityType, + tdriver.HTLCScriptIdentityType, + tdriver.MultiSigIdentityType, + tdriver.PolicyIdentityType: + return true + default: + return false + } +} + +// isCompositeIdentityType checks if the identity type is a composite type +// Composite types (MultiSig, HTLC, Policy) don't have enrollment IDs or revocation handles +func isCompositeIdentityType(t tdriver.IdentityType) bool { + switch t { + case tdriver.MultiSigIdentityType, + tdriver.HTLCScriptIdentityType, + tdriver.PolicyIdentityType: + return true + default: + return false + } +} diff --git a/token/services/identity/wallet/validation_test.go b/token/services/identity/wallet/validation_test.go new file mode 100644 index 0000000000..9420573c68 --- /dev/null +++ b/token/services/identity/wallet/validation_test.go @@ -0,0 +1,499 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package wallet_test + +import ( + "context" + "testing" + + tdriver "github.com/LFDT-Panurus/panurus/token/driver" + dmock "github.com/LFDT-Panurus/panurus/token/driver/mock" + "github.com/LFDT-Panurus/panurus/token/services/identity" + "github.com/LFDT-Panurus/panurus/token/services/identity/wallet" + "github.com/LFDT-Panurus/panurus/token/services/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateBasicStructure tests basic structure validation +func TestValidateBasicStructure(t *testing.T) { + tests := []struct { + name string + data *tdriver.RecipientData + wantErr bool + errMsg string + }{ + { + name: "nil data", + data: nil, + wantErr: true, + errMsg: "nil recipient data", + }, + { + name: "empty identity", + data: &tdriver.RecipientData{ + Identity: []byte{}, + AuditInfo: []byte("audit"), + }, + wantErr: true, + errMsg: "empty identity", + }, + { + name: "empty audit info", + data: &tdriver.RecipientData{ + Identity: []byte("identity-long-enough"), + AuditInfo: []byte{}, + }, + wantErr: true, + errMsg: "empty audit info", + }, + { + name: "identity too short", + data: &tdriver.RecipientData{ + Identity: []byte("short"), + AuditInfo: []byte(`{"key":"value"}`), + }, + wantErr: true, + errMsg: "identity too short", + }, + { + name: "identity too large", + data: &tdriver.RecipientData{ + Identity: make([]byte, wallet.MaxIdentityLength+1), // Exceeds MaxIdentityLength + AuditInfo: []byte(`{"key":"value"}`), + }, + wantErr: true, + errMsg: "identity too large", + }, + { + name: "audit info too large", + data: &tdriver.RecipientData{ + Identity: []byte("valid-identity-data"), + AuditInfo: make([]byte, 60000), // Exceeds MaxAuditInfoLength + }, + wantErr: true, + errMsg: "audit info too large", + }, + { + name: "valid data", + data: &tdriver.RecipientData{ + Identity: []byte("identity-long-enough"), + AuditInfo: []byte(`{"key":"value"}`), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + mockIP := &dmock.IdentityProvider{} + mockDeserializer := &dmock.Deserializer{} + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + err := service.RegisterRecipientIdentity(ctx, tt.data) + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + // Will fail at later validation steps, but basic structure passed + assert.Error(t, err) // Expected to fail at JSON validation or later + } + }) + } +} + +// TestValidateJSONStructure tests JSON validation +func TestValidateJSONStructure(t *testing.T) { + tests := []struct { + name string + auditInfo string + wantErr bool + }{ + { + name: "valid JSON object", + auditInfo: `{"key": "value"}`, + wantErr: false, + }, + { + name: "valid JSON array", + auditInfo: `["item1", "item2"]`, + wantErr: false, + }, + { + name: "invalid JSON", + auditInfo: `{invalid json`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + mockIP := &dmock.IdentityProvider{} + mockDeserializer := &dmock.Deserializer{} + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: []byte("identity-long-enough"), + AuditInfo: []byte(tt.auditInfo), + } + + err := service.RegisterRecipientIdentity(ctx, data) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON structure validation failed") + } else { + // Will fail at later validation steps + assert.Error(t, err) + } + }) + } +} + +// TestValidateEnrollmentID tests enrollment ID validation +func TestValidateEnrollmentID(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + eid string + wantErr bool + errMsg string + }{ + { + name: "valid EID", + eid: "alice-123", + wantErr: false, + }, + { + name: "valid EID with underscore and dot", + eid: "alice_org.example", + wantErr: false, + }, + { + name: "empty EID", + eid: "", + wantErr: true, + errMsg: "empty enrollment ID", + }, + { + // Enrollment IDs are issuer-assigned and may legitimately contain + // characters such as '@' (e.g. Idemix email-style IDs). They must + // not be rejected on charset grounds. + name: "email-style EID is accepted", + eid: "alice@example.com", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockIP := &dmock.IdentityProvider{} + mockIP.GetEnrollmentIDReturns(tt.eid, nil) + mockIP.GetRevocationHandlerReturns("rh-value", nil) + mockIP.RegisterRecipientIdentityReturns(nil) + mockIP.RegisterRecipientDataReturns(nil) + + mockDeserializer := &dmock.Deserializer{} + mockDeserializer.MatchIdentityReturns(nil) + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + // Create a valid TypedIdentity + rawIdentity := []byte("raw-identity-data") + typedIdentity, err := identity.WrapWithType(tdriver.IdemixIdentityType, rawIdentity) + require.NoError(t, err) + + data := &tdriver.RecipientData{ + Identity: typedIdentity, + AuditInfo: []byte(`{"EID":"` + tt.eid + `","RH":"rh"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateRevocationHandle tests revocation handle validation +func TestValidateRevocationHandle(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + rh string + wantErr bool + errMsg string + }{ + { + name: "valid RH", + rh: "revocation-handle-123", + wantErr: false, + }, + { + name: "empty RH", + rh: "", + wantErr: true, + errMsg: "empty revocation handle", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockIP := &dmock.IdentityProvider{} + mockIP.GetEnrollmentIDReturns("alice", nil) + mockIP.GetRevocationHandlerReturns(tt.rh, nil) + mockIP.RegisterRecipientIdentityReturns(nil) + mockIP.RegisterRecipientDataReturns(nil) + + mockDeserializer := &dmock.Deserializer{} + mockDeserializer.MatchIdentityReturns(nil) + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + // Create a valid TypedIdentity + rawIdentity := []byte("raw-identity-data") + typedIdentity, err := identity.WrapWithType(tdriver.IdemixIdentityType, rawIdentity) + require.NoError(t, err) + + data := &tdriver.RecipientData{ + Identity: typedIdentity, + AuditInfo: []byte(`{"EID":"alice","RH":"` + tt.rh + `"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateIdentityType tests identity type validation +func TestValidateIdentityType(t *testing.T) { + ctx := context.Background() + + t.Run("valid identity types", func(t *testing.T) { + validTypes := []tdriver.IdentityType{ + tdriver.IdemixIdentityType, + tdriver.X509IdentityType, + tdriver.IdemixNymIdentityType, + tdriver.HTLCScriptIdentityType, + tdriver.MultiSigIdentityType, + tdriver.PolicyIdentityType, + } + + for _, idType := range validTypes { + typedID, err := identity.WrapWithType(idType, []byte("raw-identity")) + require.NoError(t, err) + + mockIP := &dmock.IdentityProvider{} + mockIP.GetEnrollmentIDReturns("alice", nil) + mockIP.GetRevocationHandlerReturns("rh", nil) + mockIP.RegisterRecipientIdentityReturns(nil) + mockIP.RegisterRecipientDataReturns(nil) + + mockDeserializer := &dmock.Deserializer{} + mockDeserializer.MatchIdentityReturns(nil) + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: typedID, + AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + assert.NoError(t, err, "identity type %d should be valid", idType) + } + }) + + t.Run("invalid identity type", func(t *testing.T) { + // Create an identity with an invalid type (999) and sufficient length + invalidType := tdriver.IdentityType(999) + // Use longer raw identity to pass basic structure validation + typedID, err := identity.WrapWithType(invalidType, []byte("raw-identity-data-long-enough")) + require.NoError(t, err) + + mockIP := &dmock.IdentityProvider{} + // Mock GetEnrollmentID to return valid EID so validation reaches identity type check + mockIP.GetEnrollmentIDReturns("alice", nil) + mockIP.GetRevocationHandlerReturns("rh-handle", nil) + + mockDeserializer := &dmock.Deserializer{} + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: typedID, + AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown identity type") + }) + + t.Run("empty raw identity data", func(t *testing.T) { + // Create an identity with valid type but empty raw data + // Use padding to make the typed identity long enough to pass basic length check + // but the raw identity inside will still be empty + typedID, err := identity.WrapWithType(tdriver.X509IdentityType, []byte{}) + require.NoError(t, err) + + // Pad the identity to meet minimum length requirement + // The typed identity format is: [type byte][length bytes][raw identity] + // We need at least 10 bytes total + paddedIdentity := make([]byte, 10) + copy(paddedIdentity, typedID) + + mockIP := &dmock.IdentityProvider{} + // Mock to pass enrollment ID and revocation handle checks + mockIP.GetEnrollmentIDReturns("alice", nil) + mockIP.GetRevocationHandlerReturns("rh-handle", nil) + + mockDeserializer := &dmock.Deserializer{} + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: paddedIdentity, + AuditInfo: []byte(`{"EID":"alice","RH":"rh"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty raw identity") + }) + + t.Run("composite identity types skip enrollment ID validation", func(t *testing.T) { + compositeTypes := []tdriver.IdentityType{ + tdriver.MultiSigIdentityType, + tdriver.HTLCScriptIdentityType, + tdriver.PolicyIdentityType, + } + + for _, idType := range compositeTypes { + typedID, err := identity.WrapWithType(idType, []byte("raw-composite-identity")) + require.NoError(t, err) + + mockIP := &dmock.IdentityProvider{} + // Don't mock GetEnrollmentID - it should not be called for composite types + mockIP.RegisterRecipientIdentityReturns(nil) + mockIP.RegisterRecipientDataReturns(nil) + + mockDeserializer := &dmock.Deserializer{} + mockDeserializer.MatchIdentityReturns(nil) + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: typedID, + AuditInfo: []byte(`{"composite":"data"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + require.NoError(t, err, "composite identity type %d should skip enrollment ID validation", idType) + + // Verify GetEnrollmentID was NOT called + assert.Equal(t, 0, mockIP.GetEnrollmentIDCallCount(), "GetEnrollmentID should not be called for composite type %d", idType) + assert.Equal(t, 0, mockIP.GetRevocationHandlerCallCount(), "GetRevocationHandler should not be called for composite type %d", idType) + } + }) +} + +// TestRegisterRecipientIdentityFullFlow tests the complete validation flow +func TestRegisterRecipientIdentityFullFlow(t *testing.T) { + ctx := context.Background() + + t.Run("all validations pass", func(t *testing.T) { + // Create a properly typed identity (X509) + typedID, err := identity.WrapWithType(tdriver.X509IdentityType, []byte("raw-identity")) + require.NoError(t, err) + + mockIP := &dmock.IdentityProvider{} + mockIP.GetEnrollmentIDReturns("alice", nil) + mockIP.GetRevocationHandlerReturns("rh-value", nil) + mockIP.RegisterRecipientIdentityReturns(nil) + mockIP.RegisterRecipientDataReturns(nil) + + mockVerifier := &dmock.Verifier{} + mockVerifier.VerifyReturns(nil) + + mockDeserializer := &dmock.Deserializer{} + mockDeserializer.GetOwnerVerifierReturns(mockVerifier, nil) + mockDeserializer.MatchIdentityReturns(nil) + + service := wallet.NewService( + &logging.MockLogger{}, + mockIP, + mockDeserializer, + wallet.RoleRegistries{}, + ) + + data := &tdriver.RecipientData{ + Identity: typedID, + AuditInfo: []byte(`{"EID":"alice","RH":"rh-value"}`), + } + + err = service.RegisterRecipientIdentity(ctx, data) + assert.NoError(t, err) + }) +}