From 6c0ecd10bd69f785a8442c4d0df431b6a6cb0b6d Mon Sep 17 00:00:00 2001 From: William Morriss Date: Tue, 25 Nov 2025 15:11:57 -0600 Subject: [PATCH 1/4] encode hex capabilities --- pdp/contract/utils.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pdp/contract/utils.go b/pdp/contract/utils.go index e8eb8cf4b..28326d089 100644 --- a/pdp/contract/utils.go +++ b/pdp/contract/utils.go @@ -3,8 +3,10 @@ package contract import ( "context" "crypto/ecdsa" + "encoding/hex" "fmt" mbig "math/big" + "strings" "time" "github.com/ethereum/go-ethereum" @@ -109,6 +111,13 @@ func OfferingToCapabilities(offering PDPOfferingData, additionalCaps map[string] // Add custom capabilities for k, v := range additionalCaps { keys = append(keys, k) + // try hexadecimal + if len(v) % 2 == 0 && strings.HasPrefix(v, "0x") { + if decoded, err := hex.DecodeString(v[2:]); err == nil { + values = append(values, decoded) + continue + } + } values = append(values, []byte(v)) } From a5fb37416970ab871fef92d200aab0fae4100b63 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Tue, 25 Nov 2025 15:36:58 -0600 Subject: [PATCH 2/4] go fmt --- pdp/contract/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdp/contract/utils.go b/pdp/contract/utils.go index 28326d089..d5a71b1e9 100644 --- a/pdp/contract/utils.go +++ b/pdp/contract/utils.go @@ -112,7 +112,7 @@ func OfferingToCapabilities(offering PDPOfferingData, additionalCaps map[string] for k, v := range additionalCaps { keys = append(keys, k) // try hexadecimal - if len(v) % 2 == 0 && strings.HasPrefix(v, "0x") { + if len(v)%2 == 0 && strings.HasPrefix(v, "0x") { if decoded, err := hex.DecodeString(v[2:]); err == nil { values = append(values, decoded) continue From 1661264c227c63a7ff8232d9e269289f9aabacff Mon Sep 17 00:00:00 2001 From: William Morriss Date: Tue, 25 Nov 2025 16:51:38 -0600 Subject: [PATCH 3/4] test hex encoding --- pdp/contract/utils.go | 2 +- pdp/contract/utils_test.go | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pdp/contract/utils.go b/pdp/contract/utils.go index d5a71b1e9..4a367c9e3 100644 --- a/pdp/contract/utils.go +++ b/pdp/contract/utils.go @@ -112,7 +112,7 @@ func OfferingToCapabilities(offering PDPOfferingData, additionalCaps map[string] for k, v := range additionalCaps { keys = append(keys, k) // try hexadecimal - if len(v)%2 == 0 && strings.HasPrefix(v, "0x") { + if len(v)%2 == 0 && len(v) > 3 && strings.HasPrefix(v, "0x") { if decoded, err := hex.DecodeString(v[2:]); err == nil { values = append(values, decoded) continue diff --git a/pdp/contract/utils_test.go b/pdp/contract/utils_test.go index 342977f67..4ac6f34a1 100644 --- a/pdp/contract/utils_test.go +++ b/pdp/contract/utils_test.go @@ -3,6 +3,7 @@ package contract import ( "bytes" "encoding/hex" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" @@ -100,3 +101,50 @@ func TestDecodeAddressCapability_PadLeft(t *testing.T) { } } } + +func TestOfferingToCapabilities_AdditionalHex(t *testing.T) { + offering := PDPOfferingData{ + "https://pdp.example.com", + big.NewInt(32), + big.NewInt(0x1000000000000000), + false, + false, + []byte{1, 1, 1, 1, 1, 1, 1, 1, 1}, + big.NewInt(6000), + big.NewInt(30), + "narnia", + common.HexToAddress("0x0000000000004946c0e9F43F4Dee607b0eF1fA1c"), + } + additionalCaps := make(map[string]string) + additionalCaps["coolEndorsement"] = "0xccccaaaaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddaaaacccc" + additionalCaps["owner"] = "0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1" + keys, values, err := OfferingToCapabilities(offering, additionalCaps) + if err != nil { + t.Errorf("OfferingToCapabilities returned error %s", err) + } + if len(keys) != len(values) { + t.Errorf("length mismatch: %d keys for %d values", len(keys), len(values)) + } + capabilities := make(map[string][]byte) + for i := range len(keys) { + if len(keys[i]) == 0 { + t.Errorf("Got empty key at index %d", i) + } + if values[i] == nil { + t.Errorf("Got nil value for key %s", keys[i]) + } + if _, contains := capabilities[keys[i]]; contains { + t.Errorf("Got duplicate key %s", keys[i]) + } + capabilities[keys[i]] = values[i] + } + for key, valueStr := range additionalCaps { + if value, contains := capabilities[key]; !contains { + t.Errorf("keys: Missing '%s' key from additionalCaps", key) + } else if expectedLength := (len(valueStr) - 2) / 2; expectedLength != len(value) { + t.Errorf("Wrong length for hex capability '%s': expected %d actual %d", key, expectedLength, len(value)) + } else if !bytes.Equal(value, mustHex(additionalCaps[key])) { + t.Errorf("Mismatching value for key '%s': expected %s actual 0x%x", key, valueStr, value) + } + } +} From bb63130e1964a5de3766edac740c5e0b30aef590 Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 4 Dec 2025 23:01:32 +1100 Subject: [PATCH 4/4] fix(pdp): hex-encode binary capabilities for safe UI round-trip --- pdp/contract/utils.go | 30 +++++++++++ pdp/contract/utils_test.go | 101 +++++++++++++++++++++++++++++++++++++ web/api/webrpc/pdp.go | 4 +- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/pdp/contract/utils.go b/pdp/contract/utils.go index 4a367c9e3..ba8628348 100644 --- a/pdp/contract/utils.go +++ b/pdp/contract/utils.go @@ -8,6 +8,8 @@ import ( mbig "math/big" "strings" "time" + "unicode" + "unicode/utf8" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" @@ -525,3 +527,31 @@ func DecodeAddressCapability(input []byte) common.Address { // Lowest 20 bytes are the address return common.BytesToAddress(buf[12:]) } + +// ShouldHexEncodeCapability reports whether a capability value needs hex-encoding +// to safely round-trip through JSON and browser input fields. Returns true for +// invalid UTF-8 or control characters; false for valid text (including CJK/emoji). +// See https://pkg.go.dev/unicode/utf8#DecodeRune for RuneError semantics. +func ShouldHexEncodeCapability(b []byte) bool { + for i := 0; i < len(b); { + r, size := utf8.DecodeRune(b[i:]) + if r == utf8.RuneError && size == 1 { // invalid UTF-8 + return true + } + if unicode.IsControl(r) { + return true + } + i += size + } + return false +} + +// EncodeCapabilityForDisplay returns a display string for a capability value. +// Binary data gets "0x" hex prefix; valid UTF-8 text passes through as-is. +// Pairs with hex-decoding in OfferingToCapabilities. +func EncodeCapabilityForDisplay(b []byte) string { + if ShouldHexEncodeCapability(b) { + return "0x" + hex.EncodeToString(b) + } + return string(b) +} diff --git a/pdp/contract/utils_test.go b/pdp/contract/utils_test.go index 4ac6f34a1..3fd5adfd7 100644 --- a/pdp/contract/utils_test.go +++ b/pdp/contract/utils_test.go @@ -148,3 +148,104 @@ func TestOfferingToCapabilities_AdditionalHex(t *testing.T) { } } } + +func TestShouldHexEncodeCapability(t *testing.T) { + tests := []struct { + name string + input []byte + wantHex bool + }{ + // Valid UTF-8 text - should NOT be hex encoded + { + name: "ASCII text", + input: []byte("hello world"), + wantHex: false, + }, + { + name: "URL", + input: []byte("https://example.com/path?query=value"), + wantHex: false, + }, + { + name: "Chinese characters", + input: []byte("εŒ—δΊ¬"), + wantHex: false, + }, + { + name: "Japanese characters", + input: []byte("東京"), + wantHex: false, + }, + { + name: "Emoji", + input: []byte("πŸš€πŸŽ‰"), + wantHex: false, + }, + { + name: "Mixed Unicode", + input: []byte("Hello δΈ–η•Œ 🌍"), + wantHex: false, + }, + { + name: "Empty string", + input: []byte(""), + wantHex: false, + }, + + // Binary data - should be hex encoded + { + name: "Null byte", + input: []byte("hello\x00world"), + wantHex: true, + }, + { + name: "Control character (bell)", + input: []byte("hello\x07world"), + wantHex: true, + }, + { + name: "Control character (tab)", + input: []byte("hello\tworld"), + wantHex: true, + }, + { + name: "Control character (newline)", + input: []byte("hello\nworld"), + wantHex: true, + }, + { + name: "DEL character", + input: []byte("hello\x7Fworld"), + wantHex: true, + }, + { + name: "C1 control character", + input: []byte("hello\x80world"), + wantHex: true, + }, + { + name: "Invalid UTF-8 sequence", + input: []byte{0xff, 0xfe}, + wantHex: true, + }, + { + name: "Ethereum address bytes", + input: mustHex("0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1"), + wantHex: true, + }, + { + name: "Certificate-like binary with null", + input: []byte{0x30, 0x82, 0x00, 0x00, 0x02, 0x01}, + wantHex: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ShouldHexEncodeCapability(tc.input) + if got != tc.wantHex { + t.Errorf("ShouldHexEncodeCapability(%q) = %v, want %v", tc.input, got, tc.wantHex) + } + }) + } +} diff --git a/web/api/webrpc/pdp.go b/web/api/webrpc/pdp.go index 448da3e1c..ee83d1842 100644 --- a/web/api/webrpc/pdp.go +++ b/web/api/webrpc/pdp.go @@ -292,8 +292,8 @@ func capabilitiesToOffering(keys []string, values [][]byte) (*FSPDPOffering, map case contract.CapPaymentToken: offering.PaymentTokenAddress = contract.DecodeAddressCapability(value).Hex() default: - // Custom capability - customCaps[key] = string(value) + // Custom capability - encode for safe round-trip through JSON/browser + customCaps[key] = contract.EncodeCapabilityForDisplay(value) } }