From 0b08ac9131c29d681a5c179365630f75b45c38da Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 13 Jul 2026 14:27:16 +1200 Subject: [PATCH 1/6] fix(v3): sync session encoding with wallet-contracts-v3 The explicit-session and implicit-attestation encodings had drifted from the deployed v3 contracts, so any config image hash / session signature go-sequence produced was rejected on-chain. Re-sync three changes the contracts made (and mirror the sequence.js primitives JSON): - SessionPermissions: add chainId (32 bytes, after signer) and encode deadline as uint64 (8 bytes) instead of 32. Matches SessionSig recoverConfiguration and permission.ts. - Attestation authData: append issuedAt (uint64, 8 bytes) so the attestation hash matches LibAttestation.toHash. Serialize issuedAt as a decimal string in JSON to match attestation.ts. - Wire issuedAt through the cmd/sequence CLI input. Add regression tests with byte vectors verified against the contracts via forge (recoverConfiguration / LibAttestation.toHash). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/sequence/session.go | 1 + cmd/sequence/types.go | 1 + core/v3/attestation.go | 20 ++++ core/v3/permission.go | 41 ++++++-- core/v3/session_encoding_test.go | 164 +++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 core/v3/session_encoding_test.go diff --git a/cmd/sequence/session.go b/cmd/sequence/session.go index 9d19e531..c506bb1b 100644 --- a/cmd/sequence/session.go +++ b/cmd/sequence/session.go @@ -72,6 +72,7 @@ func handleEncodeSessionCallSignatures(p *EncodeSessionCallSignaturesParams) (st ApplicationData: applicationData, AuthData: v3.AuthData{ RedirectUrl: sig.Attestation.AuthData.RedirectUrl, + IssuedAt: sig.Attestation.AuthData.IssuedAt, }, } diff --git a/cmd/sequence/types.go b/cmd/sequence/types.go index fb21f713..b68413f4 100644 --- a/cmd/sequence/types.go +++ b/cmd/sequence/types.go @@ -118,6 +118,7 @@ type CallSignaturesParams struct { ApplicationData string `json:"applicationData"` AuthData struct { RedirectUrl string `json:"redirectUrl"` + IssuedAt uint64 `json:"issuedAt,string"` } `json:"authData"` } `json:"attestation"` IdentitySignature string `json:"identitySignature"` // RSV string diff --git a/core/v3/attestation.go b/core/v3/attestation.go index 61daf57f..82dc6d4a 100644 --- a/core/v3/attestation.go +++ b/core/v3/attestation.go @@ -1,9 +1,11 @@ package v3 import ( + "encoding/binary" "encoding/hex" "encoding/json" "fmt" + "strconv" "github.com/0xsequence/ethkit/go-ethereum/common" ) @@ -21,6 +23,7 @@ type Attestation struct { // AuthData represents authentication data with a redirect URL type AuthData struct { RedirectUrl string `json:"redirectUrl"` + IssuedAt uint64 `json:"issuedAt,string"` } // Encode converts an Attestation to its binary representation @@ -55,9 +58,12 @@ func (a *Attestation) Encode() []byte { // encodeAuthData converts AuthData to its binary representation func encodeAuthData(authData AuthData) []byte { redirectUrlBytes := []byte(authData.RedirectUrl) + issuedAt := make([]byte, 8) + binary.BigEndian.PutUint64(issuedAt, authData.IssuedAt) return Concat([][]byte{ intToBytes(len(redirectUrlBytes), 3), // 3 bytes for length redirectUrlBytes, // variable length + issuedAt, // uint64 (8 bytes) }) } @@ -149,6 +155,19 @@ func AttestationFromParsed(parsed map[string]interface{}) (*Attestation, error) return nil, fmt.Errorf("invalid redirectUrl") } + // issuedAt is optional and may arrive as a JSON number or string. + var issuedAt uint64 + switch v := authData["issuedAt"].(type) { + case float64: + issuedAt = uint64(v) + case string: + parsed, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid issuedAt: %w", err) + } + issuedAt = parsed + } + return &Attestation{ ApprovedSigner: common.HexToAddress(approvedSigner), IdentityType: identityTypeBytes, @@ -157,6 +176,7 @@ func AttestationFromParsed(parsed map[string]interface{}) (*Attestation, error) ApplicationData: applicationDataBytes, AuthData: AuthData{ RedirectUrl: redirectUrl, + IssuedAt: issuedAt, }, }, nil } diff --git a/core/v3/permission.go b/core/v3/permission.go index 1b3ff81e..b46fad91 100644 --- a/core/v3/permission.go +++ b/core/v3/permission.go @@ -39,6 +39,7 @@ type Permission struct { // SessionPermissions groups a signer with its associated permissions. type SessionPermissions struct { Signer common.Address `json:"signer"` + ChainID *big.Int `json:"chainId"` ValueLimit *big.Int `json:"valueLimit"` Deadline *big.Int `json:"deadline"` Permissions []Permission `json:"permissions"` @@ -61,9 +62,22 @@ func EncodeSessionPermissions(sp *SessionPermissions) ([]byte, error) { var result []byte // Append signer as 20-byte left‐padded value. result = append(result, LeftPad(sp.Signer.Bytes(), 20)...) - // Append valueLimit (32 bytes) and deadline (32 bytes). + // Append chainId (32 bytes). 0 means any chain. + chainID := sp.ChainID + if chainID == nil { + chainID = big.NewInt(0) + } + result = append(result, LeftPad(chainID.Bytes(), 32)...) + // Append valueLimit (32 bytes). result = append(result, LeftPad(sp.ValueLimit.Bytes(), 32)...) - result = append(result, LeftPad(sp.Deadline.Bytes(), 32)...) + // Append deadline (uint64, 8 bytes). + deadline := sp.Deadline + if deadline == nil { + deadline = big.NewInt(0) + } + var deadlineBuf [8]byte + deadline.FillBytes(deadlineBuf[:]) + result = append(result, deadlineBuf[:]...) // Append a single byte with the number of permissions. result = append(result, byte(len(sp.Permissions))) // Encode each permission. @@ -115,15 +129,16 @@ func boolToByte(b bool) byte { // DecodeSessionPermissions decodes a byte slice into a SessionPermissions structure. func DecodeSessionPermissions(b []byte) (SessionPermissions, error) { - if len(b) < 85 { + if len(b) < 93 { return SessionPermissions{}, fmt.Errorf("insufficient bytes for session permissions") } var sp SessionPermissions sp.Signer = common.BytesToAddress(b[0:20]) - sp.ValueLimit = new(big.Int).SetBytes(b[20:52]) - sp.Deadline = new(big.Int).SetBytes(b[52:84]) - permCount := int(b[84]) - ptr := 85 + sp.ChainID = new(big.Int).SetBytes(b[20:52]) + sp.ValueLimit = new(big.Int).SetBytes(b[52:84]) + sp.Deadline = new(big.Int).SetBytes(b[84:92]) + permCount := int(b[92]) + ptr := 93 var perms []Permission for i := 0; i < permCount; i++ { perm, consumed, err := decodePermission(b[ptr:]) @@ -275,8 +290,13 @@ func encodeSessionPermissionsForJson(sp *SessionPermissions) map[string]interfac for _, p := range sp.Permissions { perms = append(perms, encodePermissionForJson(&p)) } + chainID := sp.ChainID + if chainID == nil { + chainID = big.NewInt(0) + } return map[string]interface{}{ "signer": sp.Signer.Hex(), + "chainId": chainID.String(), "valueLimit": sp.ValueLimit.String(), "deadline": sp.Deadline.String(), "permissions": perms, @@ -355,8 +375,15 @@ func sessionPermissionsFromParsed(parsed interface{}) (SessionPermissions, error perms[i] = perm } + // chainId is optional; a missing value means any chain (0). + chainID := big.NewInt(0) + if raw, ok := m["chainId"]; ok && raw != nil { + chainID = valueToBigInt(raw) + } + return SessionPermissions{ Signer: common.HexToAddress(m["signer"].(string)), + ChainID: chainID, ValueLimit: valueToBigInt(m["valueLimit"]), Deadline: valueToBigInt(m["deadline"]), Permissions: perms, diff --git a/core/v3/session_encoding_test.go b/core/v3/session_encoding_test.go new file mode 100644 index 00000000..020bd9ac --- /dev/null +++ b/core/v3/session_encoding_test.go @@ -0,0 +1,164 @@ +package v3_test + +import ( + "encoding/hex" + "math/big" + "strings" + "testing" + + "github.com/0xsequence/ethkit/go-ethereum/common" + v3 "github.com/0xsequence/go-sequence/core/v3" +) + +// The expected byte vectors below are reference vectors verified against +// 0xsequence/wallet-contracts-v3: the SessionPermissions encoding is decoded by +// SessionSig.recoverConfiguration and the attestation hash matches +// LibAttestation.toHash (via forge tests constructing the identical structs). +// They guard the explicit-session and implicit-attestation encodings against +// silently drifting from the contracts (chainId, uint64 deadline, issuedAt). + +func hexStr(b []byte) string { return "0x" + hex.EncodeToString(b) } + +func sampleSessionPermissions() v3.SessionPermissions { + return v3.SessionPermissions{ + Signer: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ChainID: big.NewInt(42161), + ValueLimit: big.NewInt(1000000), + Deadline: big.NewInt(1678886400), + Permissions: []v3.Permission{ + { + Target: common.HexToAddress("0x2222222222222222222222222222222222222222"), + Rules: []v3.ParameterRule{ + { + Cumulative: true, + Operation: v3.GREATER_THAN_OR_EQUAL, + Value: []byte{0x01}, + Offset: big.NewInt(0), + Mask: []byte{0xff}, + }, + }, + }, + }, + } +} + +func TestEncodeSessionPermissionsParity(t *testing.T) { + const expected = "0x" + + "1111111111111111111111111111111111111111" + // signer (20) + "000000000000000000000000000000000000000000000000000000000000a4b1" + // chainId (32) = 42161 + "00000000000000000000000000000000000000000000000000000000000f4240" + // valueLimit (32) = 1000000 + "000000006411c600" + // deadline (8, uint64) = 1678886400 + "01" + // permissions count + "2222222222222222222222222222222222222222" + // target (20) + "01" + // rules count + "05" + // (GREATER_THAN_OR_EQUAL << 1) | cumulative + "0000000000000000000000000000000000000000000000000000000000000001" + // value (32) + "0000000000000000000000000000000000000000000000000000000000000000" + // offset (32) + "00000000000000000000000000000000000000000000000000000000000000ff" // mask (32) + + sp := sampleSessionPermissions() + encoded, err := v3.EncodeSessionPermissions(&sp) + if err != nil { + t.Fatalf("EncodeSessionPermissions: %v", err) + } + if got := hexStr(encoded); got != expected { + t.Errorf("encoding mismatch:\n got %v\nwant %v", got, expected) + } +} + +func TestDecodeSessionPermissionsRoundTrip(t *testing.T) { + sp := sampleSessionPermissions() + encoded, err := v3.EncodeSessionPermissions(&sp) + if err != nil { + t.Fatalf("EncodeSessionPermissions: %v", err) + } + + decoded, err := v3.DecodeSessionPermissions(encoded) + if err != nil { + t.Fatalf("DecodeSessionPermissions: %v", err) + } + + if decoded.Signer != sp.Signer { + t.Errorf("signer: got %v, want %v", decoded.Signer, sp.Signer) + } + if decoded.ChainID.Cmp(sp.ChainID) != 0 { + t.Errorf("chainId: got %v, want %v", decoded.ChainID, sp.ChainID) + } + if decoded.ValueLimit.Cmp(sp.ValueLimit) != 0 { + t.Errorf("valueLimit: got %v, want %v", decoded.ValueLimit, sp.ValueLimit) + } + if decoded.Deadline.Cmp(sp.Deadline) != 0 { + t.Errorf("deadline: got %v, want %v", decoded.Deadline, sp.Deadline) + } + if len(decoded.Permissions) != 1 || decoded.Permissions[0].Target != sp.Permissions[0].Target { + t.Fatalf("permissions did not round-trip: %+v", decoded.Permissions) + } + + // Re-encoding the decoded value must be byte-stable. + reencoded, err := v3.EncodeSessionPermissions(&decoded) + if err != nil { + t.Fatalf("re-EncodeSessionPermissions: %v", err) + } + if hexStr(reencoded) != hexStr(encoded) { + t.Errorf("re-encoding not stable:\n got %v\nwant %v", hexStr(reencoded), hexStr(encoded)) + } +} + +func sampleAttestation() v3.Attestation { + return v3.Attestation{ + ApprovedSigner: common.HexToAddress("0x3333333333333333333333333333333333333333"), + IdentityType: []byte{0xaa, 0xbb, 0xcc, 0xdd}, + IssuerHash: common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000a1").Bytes(), + AudienceHash: common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000b2").Bytes(), + ApplicationData: []byte{0xde, 0xad}, + AuthData: v3.AuthData{RedirectUrl: "https://x.example", IssuedAt: 1678886400}, + } +} + +func TestAttestationEncodingParity(t *testing.T) { + const expectedEnc = "0x" + + "3333333333333333333333333333333333333333" + // approvedSigner (20) + "aabbccdd" + // identityType (4) + "00000000000000000000000000000000000000000000000000000000000000a1" + // issuerHash (32) + "00000000000000000000000000000000000000000000000000000000000000b2" + // audienceHash (32) + "000002" + "dead" + // applicationData: uint24 length + data + "000011" + "68747470733a2f2f782e6578616d706c65" + // authData redirectUrl: uint24 length + "https://x.example" + "000000006411c600" // authData issuedAt (8, uint64) + + // keccak256(expectedEnc), verified equal to LibAttestation.toHash on-chain. + const expectedHash = "0x94b5432a124f92ecfdd6da50f16d8f254f584c00a01682dafa4668bc72dbce06" + + att := sampleAttestation() + if got := hexStr(att.Encode()); got != expectedEnc { + t.Errorf("attestation encoding mismatch:\n got %v\nwant %v", got, expectedEnc) + } + if got := hexStr(att.Hash()); got != expectedHash { + t.Errorf("attestation hash mismatch:\n got %v\nwant %v", got, expectedHash) + } +} + +func TestAttestationJSONRoundTrip(t *testing.T) { + att := sampleAttestation() + jsonStr, err := att.ToJson() + if err != nil { + t.Fatalf("ToJson: %v", err) + } + + // issuedAt must serialize as a decimal string to match the sequence.js + // primitives (attestation authData.issuedAt.toString()). + if !strings.Contains(jsonStr, `"issuedAt":"1678886400"`) { + t.Errorf("issuedAt not serialized as a string: %s", jsonStr) + } + + parsed, err := v3.AttestationFromJson(jsonStr) + if err != nil { + t.Fatalf("AttestationFromJson: %v", err) + } + + if parsed.AuthData.IssuedAt != att.AuthData.IssuedAt { + t.Errorf("issuedAt: got %v, want %v", parsed.AuthData.IssuedAt, att.AuthData.IssuedAt) + } + if hexStr(parsed.Hash()) != hexStr(att.Hash()) { + t.Errorf("hash after JSON round-trip: got %v, want %v", hexStr(parsed.Hash()), hexStr(att.Hash())) + } +} From db076d889edb87e4912205ec3328b1bf02b817e1 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 13 Jul 2026 14:38:37 +1200 Subject: [PATCH 2/6] fix(v3): drop session wallet from call digest parent wallets HashCallWithReplayProtection hashed the payload's parentWallets verbatim. The session-holding wallet is the last parent wallet in the signing context, but on chain it is the verifying contract (msg.sender), not a parent entry, so it must be dropped before hashing to match SessionSig.hashPayloadCallIdx. Wallet-bound digest comes from the partial payload replay audit fix (wallet-contracts-v3 9c19609, Code4rena S-93/S-169, #89). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/v3/session.go | 6 ++++++ core/v3/session_digest_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/core/v3/session.go b/core/v3/session.go index e7c53303..4375790b 100644 --- a/core/v3/session.go +++ b/core/v3/session.go @@ -1017,6 +1017,12 @@ func HashPayloadCallIdx(payload CallsPayload, callIdx int) (common.Hash, error) if callIdx < 0 || callIdx >= len(payload.Calls) { return common.Hash{}, fmt.Errorf("call index %v out of range [0, %v)", callIdx, len(payload.Calls)) } + // The session-holding wallet is the last parent wallet in the signing + // context, but on chain it is the verifying contract (msg.sender), not a + // parent entry. Drop it before hashing to match SessionSig.hashPayloadCallIdx. + if n := len(payload.parentWallets); n > 0 { + payload.parentWallets = payload.parentWallets[:n-1] + } payloadHash := payload.Digest().Hash var idx [32]byte big.NewInt(int64(callIdx)).FillBytes(idx[:]) diff --git a/core/v3/session_digest_test.go b/core/v3/session_digest_test.go index 4b8f1774..bc933e8a 100644 --- a/core/v3/session_digest_test.go +++ b/core/v3/session_digest_test.go @@ -84,3 +84,36 @@ func TestHashPayloadCallIdx(t *testing.T) { t.Errorf("expected out-of-range error for call index -1") } } + +// TestHashCallWithReplayProtectionParentWallets checks that the session wallet, +// which is the last parent wallet in the signing context, is dropped before +// hashing (on chain it is the verifying contract, not a parent). The expected +// digest was produced by SessionSig.hashPayloadCallIdx for the same payload +// carrying only the real parent wallet. +func TestHashCallWithReplayProtectionParentWallets(t *testing.T) { + wallet := common.HexToAddress("0x1111111111111111111111111111111111111111") + parent := common.HexToAddress("0x9999999999999999999999999999999999999999") + + payload := v3.NewCallsPayload( + wallet, + big.NewInt(42161), + []v3.Call{ + { + To: common.HexToAddress("0x2222222222222222222222222222222222222222"), + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, + big.NewInt(0), + big.NewInt(7), + []common.Address{parent, wallet}, + ) + + expected := common.HexToHash("0x29f4f05700f22db9cfe0272b824961355b2383109f0309542b9a5c495103b69f") + hash, err := v3.HashCallWithReplayProtection(payload, 0) + if err != nil { + t.Fatalf("HashCallWithReplayProtection: %v", err) + } + if hash != expected { + t.Errorf("digest mismatch: got %v, expected %v", hash, expected) + } +} From ba20be02ea685c061b83f30c9eab8560de4be950 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 13 Jul 2026 17:34:03 +1200 Subject: [PATCH 3/6] fix(v3): validate session deadline fits in uint64 EncodeSessionPermissions encodes deadline into an 8-byte buffer via big.Int.FillBytes, which panics on a negative value or one that does not fit in uint64 (e.g. a max-value "no expiry" deadline). Validate the range and return an error instead of crashing. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/v3/permission.go | 3 +++ core/v3/session_encoding_test.go | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/core/v3/permission.go b/core/v3/permission.go index b46fad91..d8713451 100644 --- a/core/v3/permission.go +++ b/core/v3/permission.go @@ -75,6 +75,9 @@ func EncodeSessionPermissions(sp *SessionPermissions) ([]byte, error) { if deadline == nil { deadline = big.NewInt(0) } + if deadline.Sign() < 0 || deadline.BitLen() > 64 { + return nil, fmt.Errorf("deadline %v out of range for uint64", deadline) + } var deadlineBuf [8]byte deadline.FillBytes(deadlineBuf[:]) result = append(result, deadlineBuf[:]...) diff --git a/core/v3/session_encoding_test.go b/core/v3/session_encoding_test.go index 020bd9ac..4111725a 100644 --- a/core/v3/session_encoding_test.go +++ b/core/v3/session_encoding_test.go @@ -104,6 +104,16 @@ func TestDecodeSessionPermissionsRoundTrip(t *testing.T) { } } +func TestEncodeSessionPermissionsDeadlineOutOfRange(t *testing.T) { + sp := sampleSessionPermissions() + // A "no expiry" deadline larger than uint64 must error, not panic. + sp.Deadline = new(big.Int).Lsh(big.NewInt(1), 64) // 2^64 + + if _, err := v3.EncodeSessionPermissions(&sp); err == nil { + t.Fatal("expected error for deadline out of uint64 range, got nil") + } +} + func sampleAttestation() v3.Attestation { return v3.Attestation{ ApprovedSigner: common.HexToAddress("0x3333333333333333333333333333333333333333"), From 920ba266ff2d7809270297926ce4696ec2c1ddb3 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 14 Jul 2026 07:05:21 +1200 Subject: [PATCH 4/6] fix(v3): validate numeric issuedAt in attestation JSON AttestationFromJson accepted a JSON-number issuedAt via an unchecked uint64 cast, so a negative, fractional or out-of-range value was silently coerced (e.g. -1 -> MaxUint64) and the library would hash/sign a different attestation than the JSON represented. Reject negatives, fractions and values outside uint64, matching the string path. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/v3/attestation.go | 4 ++++ core/v3/session_encoding_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/core/v3/attestation.go b/core/v3/attestation.go index 82dc6d4a..50080f41 100644 --- a/core/v3/attestation.go +++ b/core/v3/attestation.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "strconv" "github.com/0xsequence/ethkit/go-ethereum/common" @@ -159,6 +160,9 @@ func AttestationFromParsed(parsed map[string]interface{}) (*Attestation, error) var issuedAt uint64 switch v := authData["issuedAt"].(type) { case float64: + if v < 0 || v != math.Trunc(v) || v >= math.MaxUint64 { + return nil, fmt.Errorf("invalid issuedAt: %v", v) + } issuedAt = uint64(v) case string: parsed, err := strconv.ParseUint(v, 10, 64) diff --git a/core/v3/session_encoding_test.go b/core/v3/session_encoding_test.go index 4111725a..5022a7e9 100644 --- a/core/v3/session_encoding_test.go +++ b/core/v3/session_encoding_test.go @@ -2,6 +2,7 @@ package v3_test import ( "encoding/hex" + "fmt" "math/big" "strings" "testing" @@ -114,6 +115,34 @@ func TestEncodeSessionPermissionsDeadlineOutOfRange(t *testing.T) { } } +func TestAttestationFromJsonIssuedAtNumeric(t *testing.T) { + const tmpl = `{ + "approvedSigner": "0x3333333333333333333333333333333333333333", + "identityType": "0xaabbccdd", + "issuerHash": "0x00000000000000000000000000000000000000000000000000000000000000a1", + "audienceHash": "0x00000000000000000000000000000000000000000000000000000000000000b2", + "applicationData": "0xdead", + "authData": {"redirectUrl": "https://x.example", "issuedAt": %s} + }` + + // A valid integral number is accepted. + att, err := v3.AttestationFromJson(fmt.Sprintf(tmpl, "1678886400")) + if err != nil { + t.Fatalf("valid numeric issuedAt rejected: %v", err) + } + if att.AuthData.IssuedAt != 1678886400 { + t.Errorf("issuedAt: got %v, want 1678886400", att.AuthData.IssuedAt) + } + + // Negative, fractional and out-of-range numbers must be rejected, not + // silently coerced into a valid-looking uint64. + for _, bad := range []string{"-1", "1.5", "1e20"} { + if _, err := v3.AttestationFromJson(fmt.Sprintf(tmpl, bad)); err == nil { + t.Errorf("issuedAt %q: expected error, got nil", bad) + } + } +} + func sampleAttestation() v3.Attestation { return v3.Attestation{ ApprovedSigner: common.HexToAddress("0x3333333333333333333333333333333333333333"), From 65d06c0a7626fe922e809af2ea73ed723588eead Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 14 Jul 2026 07:29:04 +1200 Subject: [PATCH 5/6] test(v3): align parent-wallets test with HashPayloadCallIdx rename #362 was merged with the digest function renamed to HashPayloadCallIdx; update the parent-wallets test to the new name so the package builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/v3/session_digest_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/v3/session_digest_test.go b/core/v3/session_digest_test.go index bc933e8a..2d51f5ac 100644 --- a/core/v3/session_digest_test.go +++ b/core/v3/session_digest_test.go @@ -85,12 +85,12 @@ func TestHashPayloadCallIdx(t *testing.T) { } } -// TestHashCallWithReplayProtectionParentWallets checks that the session wallet, +// TestHashPayloadCallIdxParentWallets checks that the session wallet, // which is the last parent wallet in the signing context, is dropped before // hashing (on chain it is the verifying contract, not a parent). The expected // digest was produced by SessionSig.hashPayloadCallIdx for the same payload // carrying only the real parent wallet. -func TestHashCallWithReplayProtectionParentWallets(t *testing.T) { +func TestHashPayloadCallIdxParentWallets(t *testing.T) { wallet := common.HexToAddress("0x1111111111111111111111111111111111111111") parent := common.HexToAddress("0x9999999999999999999999999999999999999999") @@ -109,9 +109,9 @@ func TestHashCallWithReplayProtectionParentWallets(t *testing.T) { ) expected := common.HexToHash("0x29f4f05700f22db9cfe0272b824961355b2383109f0309542b9a5c495103b69f") - hash, err := v3.HashCallWithReplayProtection(payload, 0) + hash, err := v3.HashPayloadCallIdx(payload, 0) if err != nil { - t.Fatalf("HashCallWithReplayProtection: %v", err) + t.Fatalf("HashPayloadCallIdx: %v", err) } if hash != expected { t.Errorf("digest mismatch: got %v, expected %v", hash, expected) From 7d4a3197a658d29edb8ef7f458bf9519fef810ea Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 14 Jul 2026 07:43:59 +1200 Subject: [PATCH 6/6] fix(v3): decode session/attestation numbers safely, validate ranges Numeric JSON attributes were decoded and encoded unsafely: an unquoted number above 2^53 was silently rounded by encoding/json before use, and a negative or over-wide chainId/valueLimit was encoded as its absolute value or spilled past the fixed 32-byte field. Add bigIntFromJSON, a single decoder shared by chainId, valueLimit, deadline and attestation issuedAt: it parses strings and json.Number exactly and rejects float64 that is non-integer or at/above 2^53. EncodeSessionPermissions now rejects chainId and valueLimit that are negative or wider than uint256. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/v3/attestation.go | 19 ++++------ core/v3/permission.go | 62 +++++++++++++++++++++++++++++--- core/v3/session_encoding_test.go | 62 ++++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 18 deletions(-) diff --git a/core/v3/attestation.go b/core/v3/attestation.go index 50080f41..e88d5a8b 100644 --- a/core/v3/attestation.go +++ b/core/v3/attestation.go @@ -5,8 +5,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "math" - "strconv" "github.com/0xsequence/ethkit/go-ethereum/common" ) @@ -156,20 +154,17 @@ func AttestationFromParsed(parsed map[string]interface{}) (*Attestation, error) return nil, fmt.Errorf("invalid redirectUrl") } - // issuedAt is optional and may arrive as a JSON number or string. + // issuedAt is optional. var issuedAt uint64 - switch v := authData["issuedAt"].(type) { - case float64: - if v < 0 || v != math.Trunc(v) || v >= math.MaxUint64 { - return nil, fmt.Errorf("invalid issuedAt: %v", v) - } - issuedAt = uint64(v) - case string: - parsed, err := strconv.ParseUint(v, 10, 64) + if raw, ok := authData["issuedAt"]; ok && raw != nil { + n, err := bigIntFromJSON(raw) if err != nil { return nil, fmt.Errorf("invalid issuedAt: %w", err) } - issuedAt = parsed + if n.Sign() < 0 || n.BitLen() > 64 { + return nil, fmt.Errorf("issuedAt %v out of range for uint64", n) + } + issuedAt = n.Uint64() } return &Attestation{ diff --git a/core/v3/permission.go b/core/v3/permission.go index d8713451..d7548241 100644 --- a/core/v3/permission.go +++ b/core/v3/permission.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "math/big" "strings" @@ -67,9 +68,19 @@ func EncodeSessionPermissions(sp *SessionPermissions) ([]byte, error) { if chainID == nil { chainID = big.NewInt(0) } + if chainID.Sign() < 0 || chainID.BitLen() > 256 { + return nil, fmt.Errorf("chainId %v out of range for uint256", chainID) + } result = append(result, LeftPad(chainID.Bytes(), 32)...) // Append valueLimit (32 bytes). - result = append(result, LeftPad(sp.ValueLimit.Bytes(), 32)...) + valueLimit := sp.ValueLimit + if valueLimit == nil { + valueLimit = big.NewInt(0) + } + if valueLimit.Sign() < 0 || valueLimit.BitLen() > 256 { + return nil, fmt.Errorf("valueLimit %v out of range for uint256", valueLimit) + } + result = append(result, LeftPad(valueLimit.Bytes(), 32)...) // Append deadline (uint64, 8 bytes). deadline := sp.Deadline if deadline == nil { @@ -381,14 +392,28 @@ func sessionPermissionsFromParsed(parsed interface{}) (SessionPermissions, error // chainId is optional; a missing value means any chain (0). chainID := big.NewInt(0) if raw, ok := m["chainId"]; ok && raw != nil { - chainID = valueToBigInt(raw) + var err error + chainID, err = bigIntFromJSON(raw) + if err != nil { + return SessionPermissions{}, fmt.Errorf("invalid chainId: %w", err) + } + } + + valueLimit, err := bigIntFromJSON(m["valueLimit"]) + if err != nil { + return SessionPermissions{}, fmt.Errorf("invalid valueLimit: %w", err) + } + + deadline, err := bigIntFromJSON(m["deadline"]) + if err != nil { + return SessionPermissions{}, fmt.Errorf("invalid deadline: %w", err) } return SessionPermissions{ Signer: common.HexToAddress(m["signer"].(string)), ChainID: chainID, - ValueLimit: valueToBigInt(m["valueLimit"]), - Deadline: valueToBigInt(m["deadline"]), + ValueLimit: valueLimit, + Deadline: deadline, Permissions: perms, }, nil } @@ -477,6 +502,35 @@ func mustDecodeHex(s string) []byte { return b } +// bigIntFromJSON converts a JSON value (a decimal string or number) to a +// big.Int. Numbers are accepted only as exact integers within float64's safe +// range; larger values must be sent as strings, because encoding/json decodes +// an unquoted number into a float64 and silently rounds anything at or above +// 2^53 before it reaches here. +func bigIntFromJSON(v interface{}) (*big.Int, error) { + switch val := v.(type) { + case string: + i, ok := new(big.Int).SetString(val, 10) + if !ok { + return nil, fmt.Errorf("invalid numeric string %q", val) + } + return i, nil + case json.Number: + i, ok := new(big.Int).SetString(val.String(), 10) + if !ok { + return nil, fmt.Errorf("invalid number %q", val.String()) + } + return i, nil + case float64: + if val != math.Trunc(val) || math.Abs(val) >= 1<<53 { + return nil, fmt.Errorf("numeric value %v must be an integer within 2^53 or sent as a string", val) + } + return big.NewInt(int64(val)), nil + default: + return nil, fmt.Errorf("invalid numeric type %T", v) + } +} + // valueToBigInt converts a string (or number) to *big.Int. func valueToBigInt(v interface{}) *big.Int { switch val := v.(type) { diff --git a/core/v3/session_encoding_test.go b/core/v3/session_encoding_test.go index 5022a7e9..a352bfae 100644 --- a/core/v3/session_encoding_test.go +++ b/core/v3/session_encoding_test.go @@ -2,6 +2,7 @@ package v3_test import ( "encoding/hex" + "encoding/json" "fmt" "math/big" "strings" @@ -135,12 +136,69 @@ func TestAttestationFromJsonIssuedAtNumeric(t *testing.T) { } // Negative, fractional and out-of-range numbers must be rejected, not - // silently coerced into a valid-looking uint64. - for _, bad := range []string{"-1", "1.5", "1e20"} { + // silently coerced into a valid-looking uint64. 9007199254740993 (2^53+1) + // is rounded by encoding/json to 2^53 before we see it, so an unquoted + // value at/above 2^53 must be rejected rather than hashed as a different + // timestamp. + for _, bad := range []string{"-1", "1.5", "1e20", "9007199254740993"} { if _, err := v3.AttestationFromJson(fmt.Sprintf(tmpl, bad)); err == nil { t.Errorf("issuedAt %q: expected error, got nil", bad) } } + + // The string form preserves values that a JSON number could not. + att, err = v3.AttestationFromJson(fmt.Sprintf(tmpl, `"9007199254740993"`)) + if err != nil { + t.Fatalf("string issuedAt rejected: %v", err) + } + if att.AuthData.IssuedAt != 9007199254740993 { + t.Errorf("issuedAt: got %v, want 9007199254740993", att.AuthData.IssuedAt) + } +} + +func TestEncodeSessionPermissionsChainIdOutOfRange(t *testing.T) { + // A negative chainId must be rejected, not encoded as its absolute value + // (e.g. JSON "-1" decodes to -1, whose Bytes() is 0x01). + sp := sampleSessionPermissions() + sp.ChainID = big.NewInt(-1) + if _, err := v3.EncodeSessionPermissions(&sp); err == nil { + t.Error("expected error for negative chainId, got nil") + } + + // A chainId wider than 256 bits would spill past the fixed field. + sp = sampleSessionPermissions() + sp.ChainID = new(big.Int).Lsh(big.NewInt(1), 256) // 2^256 + if _, err := v3.EncodeSessionPermissions(&sp); err == nil { + t.Error("expected error for chainId wider than 256 bits, got nil") + } + + // Same protection for valueLimit. + sp = sampleSessionPermissions() + sp.ValueLimit = big.NewInt(-1) + if _, err := v3.EncodeSessionPermissions(&sp); err == nil { + t.Error("expected error for negative valueLimit, got nil") + } +} + +// TestSessionPermissionsNegativeChainIdRejected exercises the reviewer's +// scenario end to end: a JSON chainId of "-1" parses to a negative big.Int, and +// encoding must reject it rather than emit chain id 1. +func TestSessionPermissionsNegativeChainIdRejected(t *testing.T) { + const j = `{ + "signer": "0x9ed233eCAE5E093CAff8Ff8E147DdAfc704EC619", + "chainId": "-1", + "valueLimit": "0", + "deadline": "0", + "permissions": [] + }` + + var sp v3.SessionPermissions + if err := json.Unmarshal([]byte(j), &sp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, err := v3.EncodeSessionPermissions(&sp); err == nil { + t.Error("expected error encoding negative chainId, got nil") + } } func sampleAttestation() v3.Attestation {