Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/sequence/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func handleEncodeSessionCallSignatures(p *EncodeSessionCallSignaturesParams) (st
ApplicationData: applicationData,
AuthData: v3.AuthData{
RedirectUrl: sig.Attestation.AuthData.RedirectUrl,
IssuedAt: sig.Attestation.AuthData.IssuedAt,
},
}

Expand Down
1 change: 1 addition & 0 deletions cmd/sequence/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions core/v3/attestation.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package v3

import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"

"github.com/0xsequence/ethkit/go-ethereum/common"
)
Expand All @@ -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
Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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)
Comment on lines +161 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate numeric issuedAt before casting

When AttestationFromJson receives authData.issuedAt as a JSON number rather than the string form, this direct cast accepts invalid values instead of returning an error: for example -1 becomes math.MaxUint64, and fractional/out-of-range numbers are truncated or implementation-dependent before being appended to the attestation bytes. This can make the library hash and sign a different attestation than the JSON input represents, so the numeric path should reject negatives, fractions, and values outside uint64 just like the string path does.

Useful? React with 👍 / 👎.

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,
Expand All @@ -157,6 +176,7 @@ func AttestationFromParsed(parsed map[string]interface{}) (*Attestation, error)
ApplicationData: applicationDataBytes,
AuthData: AuthData{
RedirectUrl: redirectUrl,
IssuedAt: issuedAt,
},
}, nil
}
Expand Down
44 changes: 37 additions & 7 deletions core/v3/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -61,9 +62,25 @@ 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)
}
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[:])
Comment thread
ScreamingHawk marked this conversation as resolved.
result = append(result, deadlineBuf[:]...)
// Append a single byte with the number of permissions.
result = append(result, byte(len(sp.Permissions)))
// Encode each permission.
Expand Down Expand Up @@ -115,15 +132,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:])
Expand Down Expand Up @@ -275,8 +293,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,
Expand Down Expand Up @@ -355,8 +378,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,
Expand Down
54 changes: 22 additions & 32 deletions core/v3/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1005,18 +1005,28 @@ func isExplicitSessionCallSignature(sig SessionCallSignature) bool {
}

// --- Hashing call with replay protection ---
//
// hashCallWithReplayProtection computes the hash of a call with chain id, space and nonce.
// It concatenates 32-byte representations of chainId, space and nonce with the call hash,
// then computes the Keccak256 hash, returning it as a hex string.
func hashCallWithReplayProtection(call PayloadCall, chainId, space, nonce *big.Int) (string, error) {
chainIdB := intToBytesBig(chainId, 32)
spaceB := intToBytesBig(space, 32)
nonceB := intToBytesBig(nonce, 32)
callHash := call.HashCall() // Assume call.HashCall() returns []byte in hex format? Adjust as needed.
data := ConcatBytes(chainIdB, spaceB, nonceB, callHash)
hash := keccak256(data)
return "0x" + hex.EncodeToString(hash), nil

// HashCallWithReplayProtection computes the digest that a session key signs for
// the call at callIdx of the given payload. It matches
// SessionSig.hashPayloadCallIdx in the v3 wallet contracts:
// keccak256(Payload.hashFor(payload, wallet) ++ uint256(callIdx)), where the
// payload hash is the EIP-712 digest with the wallet as verifying contract.
// The session signature is recovered with plain ecrecover over this digest,
// without an EIP-191 prefix.
func HashCallWithReplayProtection(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]
Comment thread
ScreamingHawk marked this conversation as resolved.
}
payloadHash := payload.Digest().Hash
var idx [32]byte
big.NewInt(int64(callIdx)).FillBytes(idx[:])
return common.BytesToHash(keccak256(ConcatBytes(payloadHash.Bytes(), idx[:]))), nil
}

// --- Helper functions ---
Expand Down Expand Up @@ -1044,33 +1054,13 @@ func intToBytes(n int, size int) []byte {
return b
}

// intToBytesBig converts a *big.Int to a byte slice of a given size.
// It left-pads the number with zeros.
func intToBytesBig(n *big.Int, size int) []byte {
b := n.Bytes()
if len(b) > size {
return b[len(b)-size:]
}
padded := make([]byte, size)
copy(padded[size-len(b):], b)
return padded
}

// keccak256 computes the Keccak256 hash of the given data.
func keccak256(data []byte) []byte {
h := sha3.NewLegacyKeccak256()
h.Write(data)
return h.Sum(nil)
}

// --- PayloadCall interface ---
//
// For hashing calls we assume a minimal interface.
type PayloadCall interface {
// HashCall returns a []byte representing the call data to be hashed.
HashCall() []byte
}

// -----------------------------------------------------------------------------
// Helper Functions
// -----------------------------------------------------------------------------
Expand Down
119 changes: 119 additions & 0 deletions core/v3/session_digest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package v3_test

import (
"math/big"
"testing"

"github.com/0xsequence/ethkit/go-ethereum/common"
v3 "github.com/0xsequence/go-sequence/core/v3"
)

// The expected digests below are reference vectors produced by
// SessionSig.hashPayloadCallIdx in 0xsequence/wallet-contracts-v3 (via a forge
// test constructing the identical payloads), so this test asserts parity with
// the on-chain verification.
func TestHashCallWithReplayProtection(t *testing.T) {
payload := v3.NewCallsPayload(
common.HexToAddress("0x1111111111111111111111111111111111111111"),
big.NewInt(42161),
[]v3.Call{
{
To: common.HexToAddress("0x2222222222222222222222222222222222222222"),
Value: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), // 1 ether
Data: common.Hex2Bytes("deadbeef"),
GasLimit: big.NewInt(100000),
BehaviorOnError: v3.BehaviorOnErrorRevert,
},
{
To: common.HexToAddress("0x3333333333333333333333333333333333333333"),
DelegateCall: true,
OnlyFallback: true,
BehaviorOnError: v3.BehaviorOnErrorAbort,
},
},
big.NewInt(0),
big.NewInt(7),
)

expectedPayloadHash := common.HexToHash("0x9c891ce70c80739f54b60eb7f8a0d0e80e7f90f2c9aea51a0c4f34ec1cee067e")
if digest := payload.Digest().Hash; digest != expectedPayloadHash {
t.Errorf("payload digest mismatch: got %v, expected %v", digest, expectedPayloadHash)
}

expectedCallHashes := []common.Hash{
common.HexToHash("0x1957bab0f26824823a1f40bb132e9a6ab186db88c0adb7fb806351f6766ba66c"),
common.HexToHash("0xcb5346174462b1c9a0279b5acbdfe2458ba0825cb10d35eb912fa491c1ee5cdd"),
}
for i, expected := range expectedCallHashes {
hash, err := v3.HashCallWithReplayProtection(payload, i)
if err != nil {
t.Fatalf("HashCallWithReplayProtection(%v): %v", i, err)
}
if hash != expected {
t.Errorf("call %v hash mismatch: got %v, expected %v", i, hash, expected)
}
}

payload2 := v3.NewCallsPayload(
common.HexToAddress("0x5555555555555555555555555555555555555555"),
big.NewInt(1),
[]v3.Call{
{
To: common.HexToAddress("0x4444444444444444444444444444444444444444"),
Data: []byte{0x00},
BehaviorOnError: v3.BehaviorOnErrorIgnore,
},
},
big.NewInt(12345),
big.NewInt(0),
)

expected2 := common.HexToHash("0xde2ebba8ab9a581d22dbb5d066086ae1ab3d884f9becc493448b2875e7f3c6d4")
hash2, err := v3.HashCallWithReplayProtection(payload2, 0)
if err != nil {
t.Fatalf("HashCallWithReplayProtection: %v", err)
}
if hash2 != expected2 {
t.Errorf("vector 2 hash mismatch: got %v, expected %v", hash2, expected2)
}

if _, err := v3.HashCallWithReplayProtection(payload2, 1); err == nil {
t.Errorf("expected out-of-range error for call index 1")
}
if _, err := v3.HashCallWithReplayProtection(payload2, -1); err == nil {
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)
}
}
Loading
Loading