diff --git a/cerebro/commands.go b/cerebro/commands.go index 7dcb3051e..09ea2e76a 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -1019,6 +1019,30 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, version = existingStates[0].Version + 1 } + // SessionKeySig requires the session-key private key. Fetch it up-front and bail if the + // stored key doesn't match the address being registered — without the private key, the + // nitronode rejects the submit. + storedPK, pkErr := o.store.GetSessionKeyPrivateKey() + if pkErr != nil { + fmt.Printf("ERROR: Cannot register session key without the matching private key: %v\n", pkErr) + return + } + storedRawSigner, sigErr := sign.NewEthereumRawSigner(storedPK) + if sigErr != nil { + fmt.Printf("ERROR: Failed to load stored session key: %v\n", sigErr) + return + } + if !strings.EqualFold(storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) { + fmt.Printf("ERROR: Stored session key %s does not match the address being registered (%s)\n", + storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) + return + } + sessionKeySigner, sigErr := sign.NewEthereumMsgSignerFromRaw(storedRawSigner) + if sigErr != nil { + fmt.Printf("ERROR: Failed to construct session-key message signer: %v\n", sigErr) + return + } + expiresAt := time.Now().Add(time.Duration(expiresHours) * time.Hour) state := core.ChannelSessionKeyStateV1{ @@ -1037,6 +1061,13 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, } state.UserSig = sig + keySig, err := sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) + if err != nil { + fmt.Printf("ERROR: Failed to sign session key ownership: %v\n", err) + return + } + state.SessionKeySig = keySig + fmt.Println("Submitting channel session key state...") if err := o.client.SubmitChannelSessionKeyState(ctx, state); err != nil { fmt.Printf("ERROR: Failed to submit session key state: %v\n", err) @@ -1049,21 +1080,7 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, fmt.Printf(" Assets: %s\n", strings.Join(assets, ", ")) fmt.Printf(" Expires At: %s\n", expiresAt.Format("2006-01-02 15:04:05")) - // If we have a stored session key matching this address, activate it as the state signer - storedPK, pkErr := o.store.GetSessionKeyPrivateKey() - if pkErr != nil { - return - } - storedSigner, sigErr := sign.NewEthereumRawSigner(storedPK) - if sigErr != nil { - return - } - if !strings.EqualFold(storedSigner.PublicKey().Address().String(), sessionKeyAddr) { - return - } - - // Compute metadata hash and store full session key data - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(wallet, version, assets, expiresAt.Unix()) if err != nil { fmt.Printf("WARNING: Failed to compute metadata hash: %v\n", err) return @@ -1148,6 +1165,28 @@ func (o *Operator) createAppSessionKey(ctx context.Context, sessionKeyAddr, expi } } + // SessionKeySig requires the session-key private key. + storedPK, pkErr := o.store.GetSessionKeyPrivateKey() + if pkErr != nil { + fmt.Printf("ERROR: Cannot register session key without the matching private key: %v\n", pkErr) + return + } + storedRawSigner, sigErr := sign.NewEthereumRawSigner(storedPK) + if sigErr != nil { + fmt.Printf("ERROR: Failed to load stored session key: %v\n", sigErr) + return + } + if !strings.EqualFold(storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) { + fmt.Printf("ERROR: Stored session key %s does not match the address being registered (%s)\n", + storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) + return + } + sessionKeySigner, sigErr := sign.NewEthereumMsgSignerFromRaw(storedRawSigner) + if sigErr != nil { + fmt.Printf("ERROR: Failed to construct session-key message signer: %v\n", sigErr) + return + } + state := app.AppSessionKeyStateV1{ UserAddress: wallet, SessionKey: sessionKeyAddr, @@ -1165,6 +1204,13 @@ func (o *Operator) createAppSessionKey(ctx context.Context, sessionKeyAddr, expi } state.UserSig = sig + keySig, err := sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) + if err != nil { + fmt.Printf("ERROR: Failed to sign session key ownership: %v\n", err) + return + } + state.SessionKeySig = keySig + fmt.Println("Submitting app session key state...") if err := o.client.SubmitAppSessionKeyState(ctx, state); err != nil { fmt.Printf("ERROR: Failed to submit session key state: %v\n", err) diff --git a/docs/api.yaml b/docs/api.yaml index eb42a763d..7168b17d8 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -380,6 +380,9 @@ types: - name: user_sig type: string description: User's signature over the session key metadata to authorize the registration/update of the session key + - name: session_key_sig + type: string + description: Session-key holder's signature over PackChannelKeyStateV1(session_key, metadata_hash) — the same packed bytes user_sig signs, where metadata_hash binds user_address — proving possession of the key being registered. Required on every submit to prevent registration of keys the submitter does not control. - app_session_key_state: description: Represents the state of an app session key @@ -409,6 +412,9 @@ types: - name: user_sig type: string description: User's signature over the session key metadata to authorize the registration/update of the session key + - name: session_key_sig + type: string + description: Session-key holder's signature over the same packed state (which already binds user_address) proving possession of the key being registered. Required on every submit to prevent registration of keys the submitter does not control. - app: description: Application definition diff --git a/docs/data_models.mmd b/docs/data_models.mmd index e997137b8..4cf7584c8 100644 --- a/docs/data_models.mmd +++ b/docs/data_models.mmd @@ -194,6 +194,7 @@ classDiagram +numeric version +timestamptz expires_at +text user_sig + +text session_key_sig +timestamptz created_at +timestamptz updated_at } @@ -216,6 +217,7 @@ classDiagram +char~66~ metadata_hash +timestamptz expires_at +text user_sig + +text session_key_sig +timestamptz created_at } @@ -230,6 +232,7 @@ classDiagram +smallint kind PK +numeric version +timestamptz updated_at + UNIQUE(session_key, kind) } %% ===== BLOCKCHAIN TABLES ===== diff --git a/nitronode/api/app_session_v1/submit_session_key_state.go b/nitronode/api/app_session_v1/submit_session_key_state.go index 4015f2ad9..14d9b83b0 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -1,17 +1,15 @@ package app_session_v1 import ( + "errors" "strings" "time" - "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/log" "github.com/layer-3/nitrolite/pkg/rpc" - "github.com/layer-3/nitrolite/pkg/sign" ) // SubmitSessionKeyState processes session key state submissions for registration and updates. @@ -50,6 +48,11 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return } + if strings.EqualFold(coreState.UserAddress, coreState.SessionKey) { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key must differ from user_address"), "") + return + } + if coreState.Version == 0 { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return @@ -70,37 +73,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") return } - - // Pack the session key state for signature verification (ABI encoding) - packedState, err := app.PackAppSessionKeyStateV1(coreState) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to pack state: %v", err), "") - return - } - - // Decode the user signature - sigBytes, err := hexutil.Decode(coreState.UserSig) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to decode user_sig: %v", err), "") - return - } - - // Recover signer address from signature using ECDSA recovery - ethMsgRecoverer, err := sign.NewSigValidator(sign.TypeEthereumMsg) - if err != nil { - c.Fail(rpc.Errorf("internal_error: failed to create signature validator: %v", err), "") - return - } - - recoveredAddress, err := ethMsgRecoverer.Recover(packedState, sigBytes) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to recover signer: %v", err), "") + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") return } - // Verify the recovered address matches user_address - if !strings.EqualFold(recoveredAddress, coreState.UserAddress) { - c.Fail(rpc.Errorf("invalid_session_key_state: signature does not match user_address"), "") + // Validate both signatures: wallet's UserSig and session-key holder's SessionKeySig. + if err := app.ValidateAppSessionKeyStateV1(coreState); err != nil { + c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") return } @@ -111,6 +91,13 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // a proper "expected version" error rather than racing on the history UNIQUE constraint. latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindAppSession) if err != nil { + if errors.Is(err, database.ErrSessionKeyNotAllowed) { + logger.Warn("session key registration collision", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "kind", database.SessionKeyKindAppSession) + return rpc.Errorf("invalid_session_key_state: session_key not allowed") + } return rpc.Errorf("failed to lock session key state: %v", err) } diff --git a/nitronode/api/app_session_v1/submit_session_key_state_test.go b/nitronode/api/app_session_v1/submit_session_key_state_test.go index d99569bd5..2a7fc0fbc 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state_test.go +++ b/nitronode/api/app_session_v1/submit_session_key_state_test.go @@ -21,7 +21,9 @@ import ( ) // buildSignedSessionKeyStateReq creates a properly signed SubmitSessionKeyState request. -func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, applicationIDs, appSessionIDs []string, expiresAt time.Time, signer sign.Signer) rpc.AppSessionsV1SubmitSessionKeyStateRequest { +// signer signs the wallet UserSig; keySigner signs the SessionKeySig over the same packed +// bytes. Pass nil for keySigner to omit the field (for negative-path tests). +func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, applicationIDs, appSessionIDs []string, expiresAt time.Time, signer, keySigner sign.Signer) rpc.AppSessionsV1SubmitSessionKeyStateRequest { t.Helper() if applicationIDs == nil { @@ -46,17 +48,23 @@ func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, sig, err := signer.Sign(packed) require.NoError(t, err) - return rpc.AppSessionsV1SubmitSessionKeyStateRequest{ - State: rpc.AppSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Version: strconv.FormatUint(version, 10), - ApplicationIDs: applicationIDs, - AppSessionIDs: appSessionIDs, - ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), - UserSig: hexutil.Encode(sig), - }, + state := rpc.AppSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Version: strconv.FormatUint(version, 10), + ApplicationIDs: applicationIDs, + AppSessionIDs: appSessionIDs, + ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), + UserSig: hexutil.Encode(sig), } + + if keySigner != nil { + keySig, err := keySigner.Sign(packed) + require.NoError(t, err) + state.SessionKeySig = hexutil.Encode(keySig) + } + + return rpc.AppSessionsV1SubmitSessionKeyStateRequest{State: state} } func TestSubmitSessionKeyState_Success(t *testing.T) { @@ -81,7 +89,7 @@ func TestSubmitSessionKeyState_Success(t *testing.T) { appIDs := []string{"0x1111111111111111111111111111111111111111111111111111111111111111"} sessionIDs := []string{"0x2222222222222222222222222222222222222222222222222222222222222222"} - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) @@ -226,7 +234,7 @@ func TestSubmitSessionKeyState_AtMaxLimit(t *testing.T) { "0x4444444444444444444444444444444444444444444444444444444444444444", } - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) @@ -378,7 +386,7 @@ func TestSubmitSessionKeyState_VersionMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Submit version 3 when latest is 0 (expects 1) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) @@ -416,7 +424,7 @@ func TestSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) @@ -456,7 +464,7 @@ func TestSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, nil, nil, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, nil, nil, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(4, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) @@ -496,7 +504,7 @@ func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Sign with differentSigner but claim userAddress - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, []string{}, expiresAt, differentSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, []string{}, expiresAt, differentSigner, sessionKeySigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -511,5 +519,147 @@ func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "signature does not match user_address") + assert.Contains(t, respErr.Error(), "user_sig does not match user_address") +} + +func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // Use the wallet as its own session key — must be rejected outright. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, userAddress, 1, nil, nil, expiresAt, userSigner, userSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key must differ from user_address") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig field stays empty in the request. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig is required") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + otherSigner := NewMockSigner() + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // SessionKeySig produced by an unrelated key — declared session_key won't match the recovered address. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, otherSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig does not match session_key") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession). + Return(0, database.ErrSessionKeyNotAllowed) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key not allowed") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreAppSessionKeyState", mock.Anything) } diff --git a/nitronode/api/app_session_v1/utils.go b/nitronode/api/app_session_v1/utils.go index 4381b2d62..974553b31 100644 --- a/nitronode/api/app_session_v1/utils.go +++ b/nitronode/api/app_session_v1/utils.go @@ -270,6 +270,7 @@ func unmapSessionKeyStateV1(state *rpc.AppSessionKeyStateV1) (app.AppSessionKeyS AppSessionIDs: appSessionIDs, ExpiresAt: time.Unix(expiresAtUnix, 0), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } @@ -283,6 +284,7 @@ func mapSessionKeyStateV1(state *app.AppSessionKeyStateV1) rpc.AppSessionKeyStat AppSessionIDs: state.AppSessionIDs, ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } diff --git a/nitronode/api/channel_v1/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index e1009c53f..c3c9f8c40 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -1,6 +1,8 @@ package channel_v1 import ( + "errors" + "strings" "time" "github.com/layer-3/nitrolite/nitronode/store/database" @@ -45,6 +47,11 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return } + if strings.EqualFold(coreState.UserAddress, coreState.SessionKey) { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key must differ from user_address"), "") + return + } + if coreState.Version == 0 { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return @@ -61,9 +68,13 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") return } + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") + return + } - // Validate user's signature over the session key state - if err := core.ValidateChannelSessionKeyAuthSigV1(coreState); err != nil { + // Validate both signatures: wallet's user_sig and session-key holder's session_key_sig. + if err := core.ValidateChannelSessionKeyStateV1(coreState); err != nil { c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") return } @@ -75,6 +86,13 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // proper "expected version" error rather than racing on the history UNIQUE constraint. latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindChannel) if err != nil { + if errors.Is(err, database.ErrSessionKeyNotAllowed) { + logger.Warn("session key registration collision", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "kind", database.SessionKeyKindChannel) + return rpc.Errorf("invalid_session_key_state: session_key not allowed") + } return rpc.Errorf("failed to lock session key state: %v", err) } diff --git a/nitronode/api/channel_v1/submit_session_key_state_test.go b/nitronode/api/channel_v1/submit_session_key_state_test.go index b3d8356fc..c09706ba0 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -21,14 +21,18 @@ import ( ) // buildSignedChannelSessionKeyStateReq creates a properly signed ChannelsV1SubmitSessionKeyState request. -func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, assets []string, expiresAt time.Time, signer sign.Signer) rpc.ChannelsV1SubmitSessionKeyStateRequest { +// Both signer (wallet UserSig) and keySigner (SessionKeySig) sign over the same +// PackChannelKeyStateV1 payload. session_key is bound into the metadata hash, so a signature +// minted for one key cannot be replayed as ownership of another. Pass nil for keySigner to +// leave SessionKeySig empty for negative-path tests. +func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, assets []string, expiresAt time.Time, signer, keySigner sign.Signer) rpc.ChannelsV1SubmitSessionKeyStateRequest { t.Helper() if assets == nil { assets = []string{} } - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(strings.ToLower(userAddress), version, assets, expiresAt.Unix()) require.NoError(t, err) packed, err := core.PackChannelKeyStateV1(strings.ToLower(sessionKey), metadataHash) @@ -37,16 +41,22 @@ func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey sig, err := signer.Sign(packed) require.NoError(t, err) - return rpc.ChannelsV1SubmitSessionKeyStateRequest{ - State: rpc.ChannelSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Version: strconv.FormatUint(version, 10), - Assets: assets, - ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), - UserSig: hexutil.Encode(sig), - }, + state := rpc.ChannelSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Version: strconv.FormatUint(version, 10), + Assets: assets, + ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), + UserSig: hexutil.Encode(sig), + } + + if keySigner != nil { + keySig, err := keySigner.Sign(packed) + require.NoError(t, err) + state.SessionKeySig = hexutil.Encode(keySig) } + + return rpc.ChannelsV1SubmitSessionKeyStateRequest{State: state} } func TestChannelSubmitSessionKeyState_Success(t *testing.T) { @@ -67,7 +77,7 @@ func TestChannelSubmitSessionKeyState_Success(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) assets := []string{"USDC"} - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) @@ -145,7 +155,7 @@ func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { // Exactly at max (2) should pass validation assets := []string{"USDC", "ETH"} - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) @@ -294,7 +304,7 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Submit version 3 when latest is 0 (expects 1) - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) @@ -332,7 +342,7 @@ func TestChannelSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) @@ -373,7 +383,7 @@ func TestChannelSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Existing key at version 4: submit version 5. Cap must NOT block updates. - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, []string{"USDC"}, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(4, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) @@ -413,7 +423,7 @@ func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Sign with differentSigner but claim userAddress - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, expiresAt, differentSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, expiresAt, differentSigner, sessionKeySigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -430,3 +440,144 @@ func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { require.NotNil(t, respErr) assert.Contains(t, respErr.Error(), "does not match wallet") } + +func TestChannelSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, userAddress, 1, []string{"USDC"}, expiresAt, userSigner, userSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key must differ from user_address") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig field stays empty. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig is required") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + otherSigner := NewMockSigner() + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // SessionKeySig from a key that does not match the declared session_key. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, otherSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig does not match session_key") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel). + Return(0, database.ErrSessionKeyNotAllowed) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key not allowed") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreChannelSessionKeyState", mock.Anything) +} diff --git a/nitronode/api/channel_v1/utils.go b/nitronode/api/channel_v1/utils.go index d7e85bb72..6f5e5508b 100644 --- a/nitronode/api/channel_v1/utils.go +++ b/nitronode/api/channel_v1/utils.go @@ -224,23 +224,25 @@ func unmapChannelSessionKeyStateV1(state *rpc.ChannelSessionKeyStateV1) (core.Ch } return core.ChannelSessionKeyStateV1{ - UserAddress: strings.ToLower(state.UserAddress), - SessionKey: strings.ToLower(state.SessionKey), - Version: version, - Assets: assets, - ExpiresAt: time.Unix(expiresAtUnix, 0), - UserSig: state.UserSig, + UserAddress: strings.ToLower(state.UserAddress), + SessionKey: strings.ToLower(state.SessionKey), + Version: version, + Assets: assets, + ExpiresAt: time.Unix(expiresAtUnix, 0), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } // mapChannelSessionKeyStateV1 converts a core.ChannelSessionKeyStateV1 to an RPC ChannelSessionKeyStateV1. func mapChannelSessionKeyStateV1(state *core.ChannelSessionKeyStateV1) rpc.ChannelSessionKeyStateV1 { return rpc.ChannelSessionKeyStateV1{ - UserAddress: state.UserAddress, - SessionKey: state.SessionKey, - Version: strconv.FormatUint(state.Version, 10), - Assets: state.Assets, - ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), - UserSig: state.UserSig, + UserAddress: state.UserAddress, + SessionKey: state.SessionKey, + Version: strconv.FormatUint(state.Version, 10), + Assets: state.Assets, + ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } diff --git a/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql b/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql new file mode 100644 index 000000000..4e40d42ba --- /dev/null +++ b/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql @@ -0,0 +1,66 @@ +-- +goose Up +-- Bind a session_key to a single owner (per kind) and require co-signature at submit time. + +-- Co-signature: the session-key holder proves possession at registration and on every update. +-- Nullable to accommodate rows written before this column existed; new submits enforce non-null +-- in application code. Columns add first so a constraint failure below does not leave the +-- session_key_sig schema partially applied. +ALTER TABLE app_session_key_states_v1 + ADD COLUMN session_key_sig TEXT; + +ALTER TABLE channel_session_key_states_v1 + ADD COLUMN session_key_sig TEXT; + +-- Pre-flight: refuse the migration if duplicate (session_key, kind) rows are present in +-- current_session_key_states_v1. Such rows are evidence of cross-wallet collisions that +-- the old code path allowed; manual remediation is required before the constraint adds. +-- +goose StatementBegin +DO $$ +DECLARE + dup_count bigint; +BEGIN + SELECT COUNT(*) INTO dup_count + FROM ( + SELECT session_key, kind + FROM current_session_key_states_v1 + GROUP BY session_key, kind + HAVING COUNT(*) > 1 + ) AS dups; + + IF dup_count > 0 THEN + RAISE EXCEPTION 'duplicate (session_key, kind) rows detected (%); manual remediation required before applying constraint', dup_count; + END IF; +END $$; +-- +goose StatementEnd + +ALTER TABLE current_session_key_states_v1 + ADD CONSTRAINT current_session_key_states_v1_key_kind_uniq UNIQUE (session_key, kind); + +-- Fail closed at the DB layer for new history rows during rolling deploys: a pre-MF-H02 binary +-- (already running the MF-H01 schema where current_session_key_states_v1 exists) would happily +-- insert history rows without session_key_sig, and the new GetAppSessionKeyOwner/GetChannelSessionKeyOwner +-- lookups would then trust those unproven rows as legitimate owners. NOT VALID skips the legacy +-- backfill scan so pre-existing rows are not blocked; only future inserts are checked. +ALTER TABLE app_session_key_states_v1 + ADD CONSTRAINT app_session_key_states_v1_session_key_sig_present_chk + CHECK (session_key_sig IS NOT NULL AND session_key_sig <> '') NOT VALID; + +ALTER TABLE channel_session_key_states_v1 + ADD CONSTRAINT channel_session_key_states_v1_session_key_sig_present_chk + CHECK (session_key_sig IS NOT NULL AND session_key_sig <> '') NOT VALID; + +-- +goose Down +ALTER TABLE channel_session_key_states_v1 + DROP CONSTRAINT IF EXISTS channel_session_key_states_v1_session_key_sig_present_chk; + +ALTER TABLE app_session_key_states_v1 + DROP CONSTRAINT IF EXISTS app_session_key_states_v1_session_key_sig_present_chk; + +ALTER TABLE current_session_key_states_v1 + DROP CONSTRAINT IF EXISTS current_session_key_states_v1_key_kind_uniq; + +ALTER TABLE channel_session_key_states_v1 + DROP COLUMN IF EXISTS session_key_sig; + +ALTER TABLE app_session_key_states_v1 + DROP COLUMN IF EXISTS session_key_sig; diff --git a/nitronode/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index f582be5af..b41955974 100644 --- a/nitronode/store/database/app_session_key_state.go +++ b/nitronode/store/database/app_session_key_state.go @@ -20,6 +20,7 @@ type AppSessionKeyStateV1 struct { AppSessionIDs []AppSessionKeyAppSessionIDV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` ExpiresAt time.Time `gorm:"column:expires_at;not null"` UserSig string `gorm:"column:user_sig;not null"` + SessionKeySig string `gorm:"column:session_key_sig"` CreatedAt time.Time } @@ -58,12 +59,13 @@ func (s *DBStore) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error } dbState := AppSessionKeyStateV1{ - ID: id, - UserAddress: userAddress, - SessionKey: sessionKey, - Version: state.Version, - ExpiresAt: state.ExpiresAt.UTC(), - UserSig: state.UserSig, + ID: id, + UserAddress: userAddress, + SessionKey: sessionKey, + Version: state.Version, + ExpiresAt: state.ExpiresAt.UTC(), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } if err := s.db.Create(&dbState).Error; err != nil { @@ -239,6 +241,7 @@ func dbSessionKeyStateToCore(dbState *AppSessionKeyStateV1) app.AppSessionKeySta UserAddress: dbState.UserAddress, SessionKey: dbState.SessionKey, Version: dbState.Version, + SessionKeySig: dbState.SessionKeySig, ApplicationIDs: applicationIDs, AppSessionIDs: appSessionIDs, ExpiresAt: dbState.ExpiresAt, diff --git a/nitronode/store/database/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index be04e0d39..52be03175 100644 --- a/nitronode/store/database/channel_session_key_state.go +++ b/nitronode/store/database/channel_session_key_state.go @@ -11,15 +11,16 @@ import ( // ChannelSessionKeyStateV1 represents a channel session key state in the database. type ChannelSessionKeyStateV1 struct { - ID string `gorm:"column:id;primaryKey"` - UserAddress string `gorm:"column:user_address;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:1"` - SessionKey string `gorm:"column:session_key;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:2"` - Version uint64 `gorm:"column:version;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:3"` - Assets []ChannelSessionKeyAssetV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` - MetadataHash string `gorm:"column:metadata_hash;type:char(66);not null"` - ExpiresAt time.Time `gorm:"column:expires_at;not null"` - UserSig string `gorm:"column:user_sig;not null"` - CreatedAt time.Time + ID string `gorm:"column:id;primaryKey"` + UserAddress string `gorm:"column:user_address;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:1"` + SessionKey string `gorm:"column:session_key;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:2"` + Version uint64 `gorm:"column:version;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:3"` + Assets []ChannelSessionKeyAssetV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` + MetadataHash string `gorm:"column:metadata_hash;type:char(66);not null"` + ExpiresAt time.Time `gorm:"column:expires_at;not null"` + UserSig string `gorm:"column:user_sig;not null"` + SessionKeySig string `gorm:"column:session_key_sig"` + CreatedAt time.Time } func (ChannelSessionKeyStateV1) TableName() string { @@ -46,19 +47,20 @@ func (s *DBStore) StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV return fmt.Errorf("failed to generate session key state ID: %w", err) } - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(userAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return fmt.Errorf("failed to compute metadata hash: %w", err) } dbState := ChannelSessionKeyStateV1{ - ID: id, - UserAddress: userAddress, - SessionKey: sessionKey, - Version: state.Version, - MetadataHash: strings.ToLower(metadataHash.Hex()), - ExpiresAt: state.ExpiresAt.UTC(), - UserSig: state.UserSig, + ID: id, + UserAddress: userAddress, + SessionKey: sessionKey, + Version: state.Version, + MetadataHash: strings.ToLower(metadataHash.Hex()), + ExpiresAt: state.ExpiresAt.UTC(), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } if err := s.db.Create(&dbState).Error; err != nil { @@ -186,11 +188,12 @@ func dbChannelSessionKeyStateToCore(dbState *ChannelSessionKeyStateV1) core.Chan } return core.ChannelSessionKeyStateV1{ - UserAddress: dbState.UserAddress, - SessionKey: dbState.SessionKey, - Version: dbState.Version, - Assets: assets, - ExpiresAt: dbState.ExpiresAt, - UserSig: dbState.UserSig, + UserAddress: dbState.UserAddress, + SessionKey: dbState.SessionKey, + Version: dbState.Version, + Assets: assets, + ExpiresAt: dbState.ExpiresAt, + UserSig: dbState.UserSig, + SessionKeySig: dbState.SessionKeySig, } } diff --git a/nitronode/store/database/channel_session_key_state_test.go b/nitronode/store/database/channel_session_key_state_test.go index 1ea89e7c4..0e5c56814 100644 --- a/nitronode/store/database/channel_session_key_state_test.go +++ b/nitronode/store/database/channel_session_key_state_test.go @@ -511,7 +511,7 @@ func TestDBStore_ValidateChannelSessionKeyForAsset(t *testing.T) { // Helper to compute metadata hash for a given state computeMetadataHash := func(t *testing.T, version uint64, assets []string, expiresAt time.Time) string { t.Helper() - hash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + hash, err := core.GetChannelSessionKeyAuthMetadataHashV1(testUser1, version, assets, expiresAt.Unix()) require.NoError(t, err) return strings.ToLower(hash.Hex()) } diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 552c28dbe..2fd124b32 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -1,6 +1,7 @@ package database import ( + "errors" "fmt" "strings" "time" @@ -9,6 +10,11 @@ import ( "gorm.io/gorm/clause" ) +// ErrSessionKeyNotAllowed is returned by LockSessionKeyState when the session key for the +// requested kind is bound to a wallet other than the submitter. The message is intentionally +// generic so the API does not confirm whether a given session_key is registered elsewhere. +var ErrSessionKeyNotAllowed = errors.New("session key not allowed") + // SessionKeyKind discriminates the two session-key flavors stored in // current_session_key_states_v1. Stored as SMALLINT in the DB. type SessionKeyKind uint8 @@ -22,10 +28,15 @@ const ( // Reads of get_last_key_states JOIN this table to the corresponding history table // (channel_session_key_states_v1 or app_session_key_states_v1) on // (user_address, session_key, version), bounding per-request DB work to O(distinct keys). +// +// The uniqueIndex on (session_key, kind) mirrors the postgres constraint added by +// 20260508000000_session_key_ownership_constraints.sql so AutoMigrate (sqlite) enforces the +// same one-owner-per-key invariant that LockSessionKeyState relies on. The index name +// matches the postgres constraint name so both paths converge on a single source of truth. type CurrentSessionKeyStateV1 struct { UserAddress string `gorm:"column:user_address;primaryKey;size:42"` - SessionKey string `gorm:"column:session_key;primaryKey;size:42"` - Kind SessionKeyKind `gorm:"column:kind;primaryKey;type:smallint"` + SessionKey string `gorm:"column:session_key;primaryKey;size:42;uniqueIndex:current_session_key_states_v1_key_kind_uniq,priority:1"` + Kind SessionKeyKind `gorm:"column:kind;primaryKey;type:smallint;uniqueIndex:current_session_key_states_v1_key_kind_uniq,priority:2"` Version uint64 `gorm:"column:version;not null"` UpdatedAt time.Time `gorm:"column:updated_at"` } @@ -66,55 +77,63 @@ func upsertCurrentSessionKeyState(tx *gorm.DB, userAddress, sessionKey string, k return nil } -// LockSessionKeyState ensures a pointer row exists for (user, session_key, kind) and locks it -// for the duration of the surrounding transaction. Returns the current version (0 if newly -// created). Mirrors LockUserState. On non-postgres dialects, falls back to read-without-lock. +// LockSessionKeyState seeds the pointer row for (userAddress, session_key, kind) if absent +// and locks the (session_key, kind) row for the surrounding transaction. Returns the latest +// stored version for the caller's row, or ErrSessionKeyNotAllowed if the key is bound to a +// different wallet for this kind. +// +// The (session_key, kind) unique constraint guarantees there is at most one pointer row per +// (session_key, kind), so the SELECT ... FOR UPDATE that follows the no-op-on-conflict insert +// always converges on the same physical row regardless of who tried to seed first. A foreign +// wallet that races a legitimate owner ends up reading the legitimate owner back from the +// locked row and is rejected here, without parsing constraint-violation errors at write time. +// +// SELECT ... FOR UPDATE is postgres-only; on sqlite the locking clause is skipped and the +// surrounding transaction provides the necessary ordering for the in-process test setup. +// +// Seed-row permanence: the version=0 row written below is intentionally never deleted on +// failure paths (sig validation, version mismatch, cap exceeded, mid-tx errors). Once a wallet +// has staked a claim on (session_key, kind), no other wallet can take it for that kind — the +// seed is the ownership reservation, not a transient placeholder. CountSessionKeysForUser +// excludes version=0 rows so the per-user cap is unaffected, but the (session_key, kind) +// ownership bind is permanent by design. func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, error) { userAddress = strings.ToLower(userAddress) sessionKey = strings.ToLower(sessionKey) - if s.db.Dialector.Name() == "postgres" { - seed := CurrentSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Kind: kind, - Version: 0, - UpdatedAt: time.Now().UTC(), - } - if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { - return 0, fmt.Errorf("failed to ensure current session key state row exists: %w", err) - } + seed := CurrentSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Kind: kind, + Version: 0, + UpdatedAt: time.Now().UTC(), + } + if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { + return 0, fmt.Errorf("failed to ensure current session key state row exists: %w", err) + } - var locked CurrentSessionKeyStateV1 - err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). - First(&locked).Error - if err != nil { - return 0, fmt.Errorf("failed to lock current session key state: %w", err) - } - return locked.Version, nil + query := s.db.Where("session_key = ? AND kind = ?", sessionKey, kind) + if s.db.Dialector.Name() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } - var existing CurrentSessionKeyStateV1 - err := s.db.Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). - First(&existing).Error + var locked CurrentSessionKeyStateV1 + err := query.First(&locked).Error if err != nil { - if err == gorm.ErrRecordNotFound { - seed := CurrentSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Kind: kind, - Version: 0, - UpdatedAt: time.Now().UTC(), - } - if err := s.db.Create(&seed).Error; err != nil { - return 0, fmt.Errorf("failed to create current session key state: %w", err) - } - return 0, nil + if errors.Is(err, gorm.ErrRecordNotFound) { + // Must not happen: the seed insert above either created our row or no-op'd on + // an existing one, so a SELECT keyed on (session_key, kind) must hit a row. + // Treat as a hard error rather than falling through as unowned — silently + // returning version 0 here would let a submit bypass ownership enforcement. + return 0, fmt.Errorf("session key pointer row missing after seed insert for (session_key=%s, kind=%d)", sessionKey, kind) } - return 0, fmt.Errorf("failed to read current session key state: %w", err) + return 0, fmt.Errorf("failed to lock current session key state: %w", err) + } + + if !strings.EqualFold(locked.UserAddress, userAddress) { + return 0, ErrSessionKeyNotAllowed } - return existing.Version, nil + return locked.Version, nil } // CountSessionKeysForUser returns the number of distinct session keys recorded for the wallet diff --git a/nitronode/store/database/current_session_key_state_test.go b/nitronode/store/database/current_session_key_state_test.go index 234a49b86..91d15cb9e 100644 --- a/nitronode/store/database/current_session_key_state_test.go +++ b/nitronode/store/database/current_session_key_state_test.go @@ -1,6 +1,7 @@ package database import ( + "errors" "testing" "time" @@ -14,6 +15,48 @@ func TestCurrentSessionKeyStateV1_TableName(t *testing.T) { assert.Equal(t, "current_session_key_states_v1", CurrentSessionKeyStateV1{}.TableName()) } +// TestCurrentSessionKeyStateV1_UniqueKeyKindConstraint pins the (session_key, kind) uniqueness +// invariant at the database layer on every supported dialect. Postgres gets it from migration +// 20260508000000; sqlite gets it from the uniqueIndex gorm tag via AutoMigrate. Without the +// tag, sqlite would silently accept two pointer rows for the same key/kind under different +// wallets, breaking LockSessionKeyState's read-first-then-check ownership flow. +func TestCurrentSessionKeyStateV1_UniqueKeyKindConstraint(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + now := time.Now().UTC() + first := CurrentSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Kind: SessionKeyKindAppSession, + Version: 1, + UpdatedAt: now, + } + require.NoError(t, db.Create(&first).Error) + + // Foreign wallet attempting the same (session_key, kind) must be rejected at the + // database layer, not just by application logic. + collision := CurrentSessionKeyStateV1{ + UserAddress: testUser2, + SessionKey: testSessionKey, + Kind: SessionKeyKindAppSession, + Version: 1, + UpdatedAt: now, + } + err := db.Create(&collision).Error + require.Error(t, err) + + // Same (session_key) under a different kind is allowed — the constraint is composite. + otherKind := CurrentSessionKeyStateV1{ + UserAddress: testUser2, + SessionKey: testSessionKey, + Kind: SessionKeyKindChannel, + Version: 1, + UpdatedAt: now, + } + require.NoError(t, db.Create(&otherKind).Error) +} + func TestDBStore_LockSessionKeyState(t *testing.T) { t.Run("Seeds row at version=0 on first call", func(t *testing.T) { db, cleanup := SetupTestDB(t) @@ -73,6 +116,33 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { assert.Equal(t, uint64(0), appV) }) + t.Run("Foreign wallet trying to claim an already-owned (session_key, kind) is rejected", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // User1 owns the session key for the app-session kind. + _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + + // User2 attempts to lock the same (session_key, kind) — must surface the generic + // not-allowed sentinel without leaking that the key belongs to someone else. + _, err = store.LockSessionKeyState(testUser2, testSessionKey, SessionKeyKindAppSession) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrSessionKeyNotAllowed)) + }) + + t.Run("Same (user, session_key) across both kinds is allowed", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) + require.NoError(t, err) + _, err = store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + }) + t.Run("Lowercases user_address and session_key", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 72f87dc70..d74958c8c 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -162,8 +162,10 @@ type DatabaseStore interface { // --- Session Key State Pointer Operations --- - // LockSessionKeyState ensures the (user, session_key, kind) pointer row exists and locks - // it for the surrounding transaction. Returns the current version (0 if newly created). + // LockSessionKeyState seeds the pointer row for (user, session_key, kind) if absent and + // locks the (session_key, kind) row for the surrounding transaction. Returns the latest + // stored version for the caller's row, or ErrSessionKeyNotAllowed if the key is bound to + // a different wallet for this kind. LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, error) // CountSessionKeysForUser returns the number of distinct session keys recorded for the diff --git a/pkg/app/session_key_v1.go b/pkg/app/session_key_v1.go index 15db89adf..397fce747 100644 --- a/pkg/app/session_key_v1.go +++ b/pkg/app/session_key_v1.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" "github.com/layer-3/nitrolite/pkg/sign" ) @@ -28,6 +29,9 @@ type AppSessionKeyStateV1 struct { ExpiresAt time.Time // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string + // SessionKeySig is the session-key holder's signature over the same packed state. + // Required at submit time so that nobody can register a session key they do not control. + SessionKeySig string } // GenerateSessionKeyStateIDV1 generates a deterministic ID from user_address, session_key, and version. @@ -50,6 +54,54 @@ func GenerateSessionKeyStateIDV1(userAddress, sessionKey string, version uint64) return crypto.Keccak256Hash(packed).Hex(), nil } +// ValidateAppSessionKeyStateV1 verifies both signatures over the registration payload: +// UserSig must recover to state.UserAddress (wallet authorizes the delegation) and +// SessionKeySig must recover to state.SessionKey (session-key holder proves possession). +// Both signatures sign the same PackAppSessionKeyStateV1(state) payload, which already binds +// user_address and session_key — so a signature minted for one (wallet, session_key) pair +// cannot be replayed for another. +func ValidateAppSessionKeyStateV1(state AppSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + packed, err := PackAppSessionKeyStateV1(state) + if err != nil { + return fmt.Errorf("failed to pack session key state: %w", err) + } + + recoverer, err := sign.NewAddressRecoverer(sign.TypeEthereumMsg) + if err != nil { + return fmt.Errorf("failed to create address recoverer: %w", err) + } + + userSigBytes, err := hexutil.Decode(state.UserSig) + if err != nil { + return fmt.Errorf("failed to decode user_sig: %w", err) + } + recoveredUser, err := recoverer.RecoverAddress(packed, userSigBytes) + if err != nil { + return fmt.Errorf("failed to recover user_sig: %w", err) + } + if !strings.EqualFold(recoveredUser.String(), state.UserAddress) { + return fmt.Errorf("user_sig does not match user_address") + } + + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) + if err != nil { + return fmt.Errorf("failed to decode session_key_sig: %w", err) + } + recoveredKey, err := recoverer.RecoverAddress(packed, sessionKeySigBytes) + if err != nil { + return fmt.Errorf("failed to recover session_key_sig: %w", err) + } + if !strings.EqualFold(recoveredKey.String(), state.SessionKey) { + return fmt.Errorf("session_key_sig does not match session_key") + } + + return nil +} + // PackAppSessionKeyStateV1 packs the session key state for signing using ABI encoding. // This is used to generate a deterministic hash that the user signs when registering/updating a session key. // The user_sig field is excluded from packing since it is the signature itself. diff --git a/pkg/app/session_key_v1_test.go b/pkg/app/session_key_v1_test.go index b01748b24..4efe4e1ce 100644 --- a/pkg/app/session_key_v1_test.go +++ b/pkg/app/session_key_v1_test.go @@ -47,6 +47,89 @@ func TestGenerateSessionKeyStateIDV1(t *testing.T) { assert.NotEqual(t, id1, id3) } +func TestValidateAppSessionKeyStateV1(t *testing.T) { + t.Parallel() + userSigner, userAddress := createTestSigner(t) + sessionSigner, sessionKeyAddr := createTestSigner(t) + + version := uint64(1) + appSessionIDs := []string{ + "0x1111111111111111111111111111111111111111111111111111111111111111", + } + applicationIDs := []string{ + "0x2222222222222222222222222222222222222222222222222222222222222222", + } + expiresAt := time.Now().Add(1 * time.Hour) + + baseState := AppSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: version, + AppSessionIDs: appSessionIDs, + ApplicationIDs: applicationIDs, + ExpiresAt: expiresAt, + } + + packed, err := PackAppSessionKeyStateV1(baseState) + require.NoError(t, err) + + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + sessionKeySig, err := sessionSigner.Sign(packed) + require.NoError(t, err) + + state := baseState + state.UserSig = hexutil.Encode(userSig) + state.SessionKeySig = hexutil.Encode(sessionKeySig) + + require.NoError(t, ValidateAppSessionKeyStateV1(state)) + + // Empty session_key_sig + stateNoKeySig := state + stateNoKeySig.SessionKeySig = "" + err = ValidateAppSessionKeyStateV1(stateNoKeySig) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig is required") + + // user_sig signed by wrong wallet + wrongSigner, _ := createTestSigner(t) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateAppSessionKeyStateV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "user_sig does not match user_address") + + // session_key_sig signed by wrong key + wrongKeySigner, _ := createTestSigner(t) + wrongKeySig, err := wrongKeySigner.Sign(packed) + require.NoError(t, err) + stateWrongKey := state + stateWrongKey.SessionKeySig = hexutil.Encode(wrongKeySig) + err = ValidateAppSessionKeyStateV1(stateWrongKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig does not match session_key") + + // Tampered version (hash mismatch on recover) + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateAppSessionKeyStateV1(stateTampered)) + + // Cross-wallet replay: substitute a different user_address. Packed bytes diverge so + // neither recovery yields the matching address. + _, otherUser := createTestSigner(t) + stateCrossUser := state + stateCrossUser.UserAddress = otherUser + assert.Error(t, ValidateAppSessionKeyStateV1(stateCrossUser)) + + // Cross-session-key replay: substitute a different session_key. + _, otherKey := createTestSigner(t) + stateCrossKey := state + stateCrossKey.SessionKey = otherKey + assert.Error(t, ValidateAppSessionKeyStateV1(stateCrossKey)) +} + func TestPackAppSessionKeyStateV1(t *testing.T) { t.Parallel() expiresAt := time.Unix(1739812234, 0) diff --git a/pkg/core/session_key.go b/pkg/core/session_key.go index 0f0b91468..38d8a24fd 100644 --- a/pkg/core/session_key.go +++ b/pkg/core/session_key.go @@ -16,12 +16,13 @@ import ( // ChannelSessionKeyStateV1 represents the state of a session key. type ChannelSessionKeyStateV1 struct { // ID Hash(user_address + session_key + version) - UserAddress string `json:"user_address"` // UserAddress is the user wallet address - SessionKey string `json:"session_key"` // SessionKey is the session key address for delegation - Version uint64 `json:"version"` // Version is the version of the session key format - Assets []string `json:"assets"` // Assets associated with this session key - ExpiresAt time.Time `json:"expires_at"` // Expiration time as unix timestamp of this session key - UserSig string `json:"user_sig"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key + UserAddress string `json:"user_address"` // UserAddress is the user wallet address + SessionKey string `json:"session_key"` // SessionKey is the session key address for delegation + Version uint64 `json:"version"` // Version is the version of the session key format + Assets []string `json:"assets"` // Assets associated with this session key + ExpiresAt time.Time `json:"expires_at"` // Expiration time as unix timestamp of this session key + UserSig string `json:"user_sig"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key + SessionKeySig string `json:"session_key_sig"` // SessionKeySig is the session-key holder's signature proving possession of the key being registered. } type VerifyChannelSessionKePermissionsV1 func(walletAddr, sessionKeyAddr, metadataHash string) (bool, error) @@ -91,19 +92,25 @@ func PackChannelKeyStateV1(sessionKey string, metadataHash common.Hash) ([]byte, return packed, nil } -func GetChannelSessionKeyAuthMetadataHashV1(version uint64, assets []string, expiresAt int64) (common.Hash, error) { +// GetChannelSessionKeyAuthMetadataHashV1 hashes the session-key authorization metadata. +// user_address is bound into the hash; together with the session_key already in +// PackChannelKeyStateV1, this binds the signed payload to a single (wallet, session_key) +// pair so signatures cannot be replayed across wallets or session keys. +func GetChannelSessionKeyAuthMetadataHashV1(userAddress string, version uint64, assets []string, expiresAt int64) (common.Hash, error) { stringArrayType, err := abi.NewType("string[]", "", nil) if err != nil { return common.Hash{}, fmt.Errorf("failed to create string array type: %w", err) } metadtataArgs := abi.Arguments{ + {Type: abi.Type{T: abi.AddressTy}}, // user_address {Type: abi.Type{T: abi.UintTy, Size: 64}}, // version {Type: stringArrayType}, // assets {Type: abi.Type{T: abi.UintTy, Size: 64}}, // expires_at (unix timestamp) } packedMetadataArgs, err := metadtataArgs.Pack( + common.HexToAddress(userAddress), version, assets, uint64(expiresAt), @@ -116,8 +123,18 @@ func GetChannelSessionKeyAuthMetadataHashV1(version uint64, assets []string, exp return hashedMetadata, nil } -func ValidateChannelSessionKeyAuthSigV1(state ChannelSessionKeyStateV1) error { - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) +// ValidateChannelSessionKeyStateV1 verifies both signatures over the registration payload: +// user_sig must recover to state.UserAddress (wallet authorizes the delegation) and +// session_key_sig must recover to state.SessionKey (session-key holder proves possession). +// Both signatures sign the same PackChannelKeyStateV1(session_key, metadataHash) payload; +// session_key binds the packed bytes and user_address binds the metadata hash, so a +// signature minted for one (wallet, session_key) pair cannot be replayed for another. +func ValidateChannelSessionKeyStateV1(state ChannelSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return fmt.Errorf("failed to get metadata hash: %w", err) } @@ -127,23 +144,33 @@ func ValidateChannelSessionKeyAuthSigV1(state ChannelSessionKeyStateV1) error { return fmt.Errorf("failed to pack session key state: %w", err) } - authSigBytes, err := hexutil.Decode(state.UserSig) - if err != nil { - return fmt.Errorf("failed to decode user signature: %w", err) - } - recoverer, err := sign.NewAddressRecoverer(sign.TypeEthereumMsg) if err != nil { return fmt.Errorf("failed to create address recoverer: %w", err) } - recoveredAddr, err := recoverer.RecoverAddress(packed, authSigBytes) + userSigBytes, err := hexutil.Decode(state.UserSig) if err != nil { - return fmt.Errorf("failed to recover address from signature: %w", err) + return fmt.Errorf("failed to decode user signature: %w", err) + } + recoveredUser, err := recoverer.RecoverAddress(packed, userSigBytes) + if err != nil { + return fmt.Errorf("failed to recover user_sig: %w", err) + } + if !strings.EqualFold(recoveredUser.String(), state.UserAddress) { + return fmt.Errorf("invalid signature: recovered address %s does not match wallet %s", recoveredUser.String(), state.UserAddress) } - if !strings.EqualFold(recoveredAddr.String(), state.UserAddress) { - return fmt.Errorf("invalid signature: recovered address %s does not match wallet %s", recoveredAddr.String(), state.UserAddress) + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) + if err != nil { + return fmt.Errorf("failed to decode session_key_sig: %w", err) + } + recoveredKey, err := recoverer.RecoverAddress(packed, sessionKeySigBytes) + if err != nil { + return fmt.Errorf("failed to recover session_key_sig: %w", err) + } + if !strings.EqualFold(recoveredKey.String(), state.SessionKey) { + return fmt.Errorf("session_key_sig does not match session_key") } return nil diff --git a/pkg/core/session_key_test.go b/pkg/core/session_key_test.go index c21d999d1..23e4bd473 100644 --- a/pkg/core/session_key_test.go +++ b/pkg/core/session_key_test.go @@ -27,7 +27,7 @@ func TestChannelSessionKeySignerV1(t *testing.T) { expiresAt := time.Now().Add(1 * time.Hour).Unix() // 4. Compute Metadata Hash - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt) + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(userAddress, version, assets, expiresAt) require.NoError(t, err) // 5. Pack Data for Authorization (User signs this) @@ -69,58 +69,122 @@ func TestChannelSessionKeySignerV1(t *testing.T) { assert.Equal(t, strings.ToLower(userAddress), strings.ToLower(recoveredWallet)) } -func TestValidateChannelSessionKeyAuthSigV1(t *testing.T) { +func TestValidateChannelSessionKeyStateV1(t *testing.T) { t.Parallel() - // 1. Setup User Wallet userSigner, userAddress := createSigner(t) + sessionSigner, sessionKeyAddr := createSigner(t) - // 2. Setup Session Key - // We just need address for validation logic, not the signer itself unless we sign with it (which we don't for auth sig) - // But let's use createSigner for consistency - _, sessionKeyAddr := createSigner(t) - - // 3. Define State version := uint64(1) assets := []string{"USDC"} expiresAt := time.Now().Add(1 * time.Hour) - // 4. Create valid signature - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(userAddress, version, assets, expiresAt.Unix()) require.NoError(t, err) packed, err := PackChannelKeyStateV1(sessionKeyAddr, metadataHash) require.NoError(t, err) - authSig, err := userSigner.Sign(packed) + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + + sessionKeySig, err := sessionSigner.Sign(packed) require.NoError(t, err) state := ChannelSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKeyAddr, - Version: version, - Assets: assets, - ExpiresAt: expiresAt, - UserSig: hexutil.Encode(authSig), + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: version, + Assets: assets, + ExpiresAt: expiresAt, + UserSig: hexutil.Encode(userSig), + SessionKeySig: hexutil.Encode(sessionKeySig), } - // 5. Validate - err = ValidateChannelSessionKeyAuthSigV1(state) - require.NoError(t, err) + require.NoError(t, ValidateChannelSessionKeyStateV1(state)) + + // Empty session_key_sig + stateNoKeySig := state + stateNoKeySig.SessionKeySig = "" + err = ValidateChannelSessionKeyStateV1(stateNoKeySig) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig is required") - // 6. Test Invalid Signature (wrong signer) + // user_sig signed by wrong wallet wrongSigner, _ := createSigner(t) - wrongSig, err := wrongSigner.Sign(packed) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateChannelSessionKeyStateV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match wallet") + + // session_key_sig signed by wrong key + wrongKeySigner, _ := createSigner(t) + wrongKeySig, err := wrongKeySigner.Sign(packed) + require.NoError(t, err) + stateWrongKey := state + stateWrongKey.SessionKeySig = hexutil.Encode(wrongKeySig) + err = ValidateChannelSessionKeyStateV1(stateWrongKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig does not match session_key") + + // Tampered version (hash mismatch on recover) + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateChannelSessionKeyStateV1(stateTampered)) +} + +// TestValidateChannelSessionKeyStateV1_NoReplay verifies that signatures cannot be replayed +// across (wallet, session_key) pairs. session_key binds the packed payload and user_address +// binds the metadata hash, so substituting either dimension causes signature recovery to +// yield an unrelated address. +func TestValidateChannelSessionKeyStateV1_NoReplay(t *testing.T) { + t.Parallel() + userSignerA, userAddressA := createSigner(t) + _, userAddressB := createSigner(t) + + sessionSignerA, sessionKeyAddrA := createSigner(t) + _, sessionKeyAddrB := createSigner(t) + + version := uint64(1) + assets := []string{"USDC"} + expiresAt := time.Now().Add(1 * time.Hour) + + metadataHashA, err := GetChannelSessionKeyAuthMetadataHashV1(userAddressA, version, assets, expiresAt.Unix()) + require.NoError(t, err) + packedA, err := PackChannelKeyStateV1(sessionKeyAddrA, metadataHashA) require.NoError(t, err) - state.UserSig = hexutil.Encode(wrongSig) - err1 := ValidateChannelSessionKeyAuthSigV1(state) - require.Error(t, err1) - assert.Contains(t, err1.Error(), "does not match wallet") + userSigA, err := userSignerA.Sign(packedA) + require.NoError(t, err) + sessionKeySigA, err := sessionSignerA.Sign(packedA) + require.NoError(t, err) - // 7. Test Invalid Signature (wrong data) - state.UserSig = hexutil.Encode(authSig) // Reset sig - state.Version = 2 // Change data - assert.Error(t, ValidateChannelSessionKeyAuthSigV1(state)) // Hash mismatch leads to recover address mismatch + stateA := ChannelSessionKeyStateV1{ + UserAddress: userAddressA, + SessionKey: sessionKeyAddrA, + Version: version, + Assets: assets, + ExpiresAt: expiresAt, + UserSig: hexutil.Encode(userSigA), + SessionKeySig: hexutil.Encode(sessionKeySigA), + } + require.NoError(t, ValidateChannelSessionKeyStateV1(stateA)) + + // Cross-session_key replay: substitute sessionKeyAddrB. packed bytes diverge, both + // recoveries yield unrelated addresses. + stateCrossKey := stateA + stateCrossKey.SessionKey = sessionKeyAddrB + err = ValidateChannelSessionKeyStateV1(stateCrossKey) + require.Error(t, err) + + // Cross-wallet replay: substitute userAddressB. metadataHash diverges, packed bytes + // diverge, both recoveries yield unrelated addresses. + stateCrossUser := stateA + stateCrossUser.UserAddress = userAddressB + err = ValidateChannelSessionKeyStateV1(stateCrossUser) + require.Error(t, err) } func TestGenerateSessionKeyStateIDV1(t *testing.T) { diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go index 2291302d0..02ea60ea3 100644 --- a/pkg/rpc/types.go +++ b/pkg/rpc/types.go @@ -71,6 +71,8 @@ type ChannelSessionKeyStateV1 struct { ExpiresAt string `json:"expires_at"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string `json:"user_sig"` + // SessionKeySig is the session-key holder's signature proving possession of the key being registered. + SessionKeySig string `json:"session_key_sig"` } // ============================================================================ @@ -214,6 +216,8 @@ type AppSessionKeyStateV1 struct { ExpiresAt string `json:"expires_at"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string `json:"user_sig"` + // SessionKeySig is the session-key holder's signature proving possession of the key being registered. + SessionKeySig string `json:"session_key_sig"` } // ============================================================================ diff --git a/pkg/sign/eth_msg_signer.go b/pkg/sign/eth_msg_signer.go index 8b376f07d..7f4fe1779 100644 --- a/pkg/sign/eth_msg_signer.go +++ b/pkg/sign/eth_msg_signer.go @@ -20,8 +20,8 @@ func (s *EthereumMsgSigner) Sign(hash []byte) (Signature, error) { return Signature(sig), nil } -// NewEthereumRawSigner creates a new Ethereum signer from a hex-encoded private key. -func NewEthereumMsgSigner(privateKeyHex string) (Signer, error) { +// NewEthereumMsgSigner creates a new Ethereum signer from a hex-encoded private key. +func NewEthereumMsgSigner(privateKeyHex string) (*EthereumMsgSigner, error) { signer, err := NewEthereumRawSigner(privateKeyHex) if err != nil { return nil, err @@ -30,8 +30,8 @@ func NewEthereumMsgSigner(privateKeyHex string) (Signer, error) { return NewEthereumMsgSignerFromRaw(signer) } -// NewEthereumRawSignerFronRaw creates a new Ethereum signer from an existing Signer instance. -func NewEthereumMsgSignerFromRaw(signer Signer) (Signer, error) { +// NewEthereumMsgSignerFromRaw creates a new Ethereum signer from an existing Signer instance. +func NewEthereumMsgSignerFromRaw(signer Signer) (*EthereumMsgSigner, error) { return &EthereumMsgSigner{ signer, }, nil diff --git a/sdk/go/README.md b/sdk/go/README.md index d021a3718..053203c82 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -68,15 +68,17 @@ client.RebalanceAppSessions(ctx, signedUpdates) // Atomic rebalanc ### Session Keys — App Sessions ```go -client.SignSessionKeyState(state) // Sign an app session key state -client.SubmitAppSessionKeyState(ctx, state) // Register/update app session key +client.SignSessionKeyState(state) // Wallet UserSig over app session key state +sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's SessionKeySig +client.SubmitAppSessionKeyState(ctx, state) // Register/update app session key (both sigs required) client.GetLastAppKeyStates(ctx, userAddress, opts) // Get active app session key states ``` ### Session Keys — Channels ```go -client.SignChannelSessionKeyState(state) // Sign a channel session key state -client.SubmitChannelSessionKeyState(ctx, state) // Register/update channel session key +client.SignChannelSessionKeyState(state) // Wallet UserSig over channel session key state +sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's SessionKeySig +client.SubmitChannelSessionKeyState(ctx, state) // Register/update channel session key (both sigs required) client.GetLastChannelKeyStates(ctx, userAddress, opts) // Get active channel session key states ``` @@ -418,8 +420,15 @@ sig, _ := appSessionSigner.Sign(packedRequest) ### Session Keys — App Sessions +Registration requires two signatures: the wallet's `UserSig` (authorizing the delegation) +and the session-key holder's `SessionKeySig` (proving possession of the key being +registered). The node rejects submits that lack a valid `SessionKeySig`. + ```go -// Sign and submit an app session key state +// sessionKeySigner must be a *sign.EthereumMsgSigner (raw EIP-191 signer) +// whose address equals state.SessionKey — not a wrapped sign.Signer, because +// the node recovers SessionKeySig as a raw 65-byte Ethereum message signature. +sessionKeySigner, _ := sign.NewEthereumMsgSigner(sessionKeyPrivHex) state := app.AppSessionKeyStateV1{ UserAddress: client.GetUserAddress(), SessionKey: "0xSessionKey...", @@ -428,9 +437,9 @@ state := app.AppSessionKeyStateV1{ AppSessionIDs: []string{}, ExpiresAt: time.Now().Add(24 * time.Hour), } -sig, err := client.SignSessionKeyState(state) -state.UserSig = sig -err = client.SubmitAppSessionKeyState(ctx, state) +state.UserSig, _ = client.SignSessionKeyState(state) +state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) +err := client.SubmitAppSessionKeyState(ctx, state) // Query active app session key states states, err := client.GetLastAppKeyStates(ctx, userAddress, nil) @@ -442,7 +451,10 @@ states, err := client.GetLastAppKeyStates(ctx, userAddress, &sdk.GetLastKeyState ### Session Keys — Channels ```go -// Sign and submit a channel session key state +// sessionKeySigner must be a *sign.EthereumMsgSigner (raw EIP-191 signer) +// whose address equals state.SessionKey — not a wrapped sign.Signer, because +// the node recovers SessionKeySig as a raw 65-byte Ethereum message signature. +sessionKeySigner, _ := sign.NewEthereumMsgSigner(sessionKeyPrivHex) state := core.ChannelSessionKeyStateV1{ UserAddress: client.GetUserAddress(), SessionKey: "0xSessionKey...", @@ -450,9 +462,9 @@ state := core.ChannelSessionKeyStateV1{ Assets: []string{"usdc", "weth"}, ExpiresAt: time.Now().Add(24 * time.Hour), } -sig, err := client.SignChannelSessionKeyState(state) -state.UserSig = sig -err = client.SubmitChannelSessionKeyState(ctx, state) +state.UserSig, _ = client.SignChannelSessionKeyState(state) +state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) +err := client.SubmitChannelSessionKeyState(ctx, state) // Query active channel session key states states, err := client.GetLastChannelKeyStates(ctx, userAddress, nil) diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index cd91b852e..79b9d24fc 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -8,6 +8,7 @@ import ( "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/rpc" + "github.com/layer-3/nitrolite/pkg/sign" "github.com/shopspring/decimal" ) @@ -281,7 +282,9 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // ============================================================================ // SubmitAppSessionKeyState submits a session key state for registration or update. -// The state must be signed by the user's wallet to authorize the session key delegation. +// The state must carry both the wallet's UserSig (proving the user authorized the +// delegation) and the session-key holder's SessionKeySig (proving possession of the key +// being registered). Submits without a valid SessionKeySig are rejected. // // Parameters: // - state: The session key state containing delegation information @@ -298,8 +301,9 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // ApplicationIDs: []string{"app1"}, // AppSessionIDs: []string{}, // ExpiresAt: time.Now().Add(24 * time.Hour), -// UserSig: "0x...", // } +// state.UserSig, _ = client.SignSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) // err := client.SubmitAppSessionKeyState(ctx, state) func (c *Client) SubmitAppSessionKeyState(ctx context.Context, state app.AppSessionKeyStateV1) error { req := rpc.AppSessionsV1SubmitSessionKeyStateRequest{ @@ -355,12 +359,13 @@ func (c *Client) GetLastAppKeyStates(ctx context.Context, userAddress string, op return states, nil } -// SignSessionKeyState signs a session key state using the client's state signer. -// This creates a properly formatted signature that can be set on the state's UserSig field -// before submitting via SubmitSessionKeyState. +// SignSessionKeyState produces the wallet UserSig over the session key state using the +// client's state signer. Set the returned hex on state.UserSig before submit. The matching +// session-key-holder SessionKeySig must also be populated (see SignAppSessionKeyOwnership) +// — submits with only one of the two are rejected. // // Parameters: -// - state: The session key state to sign (UserSig field is excluded from signing) +// - state: The session key state to sign (UserSig and SessionKeySig fields are excluded from signing) // // Returns: // - The hex-encoded signature string @@ -376,9 +381,9 @@ func (c *Client) GetLastAppKeyStates(ctx context.Context, userAddress string, op // AppSessionIDs: []string{}, // ExpiresAt: time.Now().Add(24 * time.Hour), // } -// sig, err := client.SignSessionKeyState(state) -// state.UserSig = sig -// err = client.SubmitSessionKeyState(ctx, state) +// state.UserSig, _ = client.SignSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) +// err = client.SubmitAppSessionKeyState(ctx, state) func (c *Client) SignSessionKeyState(state app.AppSessionKeyStateV1) (string, error) { packed, err := app.PackAppSessionKeyStateV1(state) if err != nil { @@ -393,3 +398,26 @@ func (c *Client) SignSessionKeyState(state app.AppSessionKeyStateV1) (string, er // Strip the channel signer type prefix byte; session key auth uses plain EIP-191 signatures return hexutil.Encode(sig[1:]), nil } + +// SignAppSessionKeyOwnership produces the session-key holder's ownership signature over the +// packed app-session key state. The signer must be the holder of the session key being +// registered; the resulting hex-encoded signature is intended to populate state.SessionKeySig +// before submitting via SubmitAppSessionKeyState. The packed state already binds user_address, +// so replay across wallets is not possible. +// +// The parameter is narrowed to *sign.EthereumMsgSigner because the server recovers +// SessionKeySig under sign.TypeEthereumMsg — a broader signer interface could produce a +// signature without the EIP-191 prefix (or with extra wrapper bytes) that the server rejects. +func SignAppSessionKeyOwnership(state app.AppSessionKeyStateV1, sessionKeySigner *sign.EthereumMsgSigner) (string, error) { + packed, err := app.PackAppSessionKeyStateV1(state) + if err != nil { + return "", fmt.Errorf("failed to pack session key state: %w", err) + } + + sig, err := sessionKeySigner.Sign(packed) + if err != nil { + return "", fmt.Errorf("failed to sign session key ownership: %w", err) + } + + return hexutil.Encode(sig), nil +} diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 380150a51..68f90a18c 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -851,7 +851,9 @@ type GetLastChannelKeyStatesOptions struct { } // SubmitChannelSessionKeyState submits a channel session key state for registration or update. -// The state must be signed by the user's wallet to authorize the session key delegation. +// The state must carry both the wallet's UserSig (proving the user authorized the +// delegation) and the session-key holder's SessionKeySig (proving possession of the key +// being registered). Submits without a valid SessionKeySig are rejected. // // Parameters: // - state: The channel session key state containing delegation information @@ -867,8 +869,9 @@ type GetLastChannelKeyStatesOptions struct { // Version: 1, // Assets: []string{"usdc", "weth"}, // ExpiresAt: time.Now().Add(24 * time.Hour), -// UserSig: "0x...", // } +// state.UserSig, _ = client.SignChannelSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // err := client.SubmitChannelSessionKeyState(ctx, state) func (c *Client) SubmitChannelSessionKeyState(ctx context.Context, state core.ChannelSessionKeyStateV1) error { req := rpc.ChannelsV1SubmitSessionKeyStateRequest{ @@ -918,12 +921,13 @@ func (c *Client) GetLastChannelKeyStates(ctx context.Context, userAddress string return states, nil } -// SignChannelSessionKeyState signs a channel session key state using the client's state signer. -// This creates a properly formatted signature that can be set on the state's UserSig field -// before submitting via SubmitChannelSessionKeyState. +// SignChannelSessionKeyState produces the wallet UserSig over the channel session key +// state using the client's state signer. Set the returned hex on state.UserSig before +// submit. The matching session-key-holder SessionKeySig must also be populated (see +// SignChannelSessionKeyOwnership) — submits with only one of the two are rejected. // // Parameters: -// - state: The channel session key state to sign (UserSig field is excluded from signing) +// - state: The channel session key state to sign (UserSig and SessionKeySig fields are excluded from signing) // // Returns: // - The hex-encoded signature string @@ -938,11 +942,11 @@ func (c *Client) GetLastChannelKeyStates(ctx context.Context, userAddress string // Assets: []string{"usdc"}, // ExpiresAt: time.Now().Add(24 * time.Hour), // } -// sig, err := client.SignChannelSessionKeyState(state) -// state.UserSig = sig +// state.UserSig, _ = client.SignChannelSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // err = client.SubmitChannelSessionKeyState(ctx, state) func (c *Client) SignChannelSessionKeyState(state core.ChannelSessionKeyStateV1) (string, error) { - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return "", fmt.Errorf("failed to compute metadata hash: %w", err) } @@ -965,6 +969,33 @@ func (c *Client) SignChannelSessionKeyState(state core.ChannelSessionKeyStateV1) return sig.String(), nil } +// SignChannelSessionKeyOwnership produces the session-key holder's ownership signature for a +// channel session key registration. The signer must hold the session key; the returned hex +// string populates state.SessionKeySig before submit. The signed payload binds session_key +// into the metadata hash so a signature minted for one key cannot be replayed for another. +// +// The parameter is narrowed to *sign.EthereumMsgSigner because the server recovers +// SessionKeySig under sign.TypeEthereumMsg — a broader signer interface could produce a +// signature without the EIP-191 prefix (or with extra wrapper bytes) that the server rejects. +func SignChannelSessionKeyOwnership(state core.ChannelSessionKeyStateV1, sessionKeySigner *sign.EthereumMsgSigner) (string, error) { + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) + if err != nil { + return "", fmt.Errorf("failed to compute metadata hash: %w", err) + } + + packed, err := core.PackChannelKeyStateV1(state.SessionKey, metadataHash) + if err != nil { + return "", fmt.Errorf("failed to pack channel session key state: %w", err) + } + + sig, err := sessionKeySigner.Sign(packed) + if err != nil { + return "", fmt.Errorf("failed to sign channel session key ownership: %w", err) + } + + return sig.String(), nil +} + // ApproveToken approves the ChannelHub contract to spend tokens on behalf of the user. // This is required before depositing ERC-20 tokens. Native tokens (e.g., ETH) do not // require approval and will return an error if attempted. diff --git a/sdk/go/examples/app_sessions/lifecycle.go b/sdk/go/examples/app_sessions/lifecycle.go index efe0dbda7..3cb586df2 100644 --- a/sdk/go/examples/app_sessions/lifecycle.go +++ b/sdk/go/examples/app_sessions/lifecycle.go @@ -207,12 +207,21 @@ func main() { log.Fatal(err) } + // Wallet's UserSig authorizes the delegation. appSessionKey3StateSig, err := wallet3Signer.Sign(packedAppSessionKey3State) if err != nil { log.Fatal(err) } appSessionKey3State.UserSig = appSessionKey3StateSig.String() + // Session-key holder's SessionKeySig proves possession of the key being registered. + // Both signatures are required at submit time. + appSessionKey3OwnershipSig, err := msgSigner3.Sign(packedAppSessionKey3State) + if err != nil { + log.Fatal(err) + } + appSessionKey3State.SessionKeySig = appSessionKey3OwnershipSig.String() + if err := wallet3Client.SubmitAppSessionKeyState(context.Background(), appSessionKey3State); err != nil { log.Fatal(err) } diff --git a/sdk/go/utils.go b/sdk/go/utils.go index 88fc8bb14..2835a853f 100644 --- a/sdk/go/utils.go +++ b/sdk/go/utils.go @@ -513,12 +513,13 @@ func transformSignedAppStateUpdateToRPC(signed app.SignedAppStateUpdateV1) rpc.S // transformChannelSessionKeyStateToRPC converts core.ChannelSessionKeyStateV1 to RPC ChannelSessionKeyStateV1. func transformChannelSessionKeyStateToRPC(state core.ChannelSessionKeyStateV1) rpc.ChannelSessionKeyStateV1 { return rpc.ChannelSessionKeyStateV1{ - UserAddress: state.UserAddress, - SessionKey: state.SessionKey, - Version: strconv.FormatUint(state.Version, 10), - Assets: state.Assets, - ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), - UserSig: state.UserSig, + UserAddress: state.UserAddress, + SessionKey: state.SessionKey, + Version: strconv.FormatUint(state.Version, 10), + Assets: state.Assets, + ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } @@ -540,12 +541,13 @@ func transformChannelSessionKeyState(state rpc.ChannelSessionKeyStateV1) (core.C } return core.ChannelSessionKeyStateV1{ - UserAddress: strings.ToLower(state.UserAddress), - SessionKey: strings.ToLower(state.SessionKey), - Version: version, - Assets: assets, - ExpiresAt: time.Unix(expiresAtUnix, 0), - UserSig: state.UserSig, + UserAddress: strings.ToLower(state.UserAddress), + SessionKey: strings.ToLower(state.SessionKey), + Version: version, + Assets: assets, + ExpiresAt: time.Unix(expiresAtUnix, 0), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } @@ -576,6 +578,7 @@ func transformSessionKeyStateToRPC(state app.AppSessionKeyStateV1) rpc.AppSessio AppSessionIDs: state.AppSessionIDs, ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } @@ -609,6 +612,7 @@ func transformSessionKeyState(state rpc.AppSessionKeyStateV1) (app.AppSessionKey AppSessionIDs: appSessionIDs, ExpiresAt: time.Unix(expiresAtUnix, 0), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index ca2fc28f1..bafbd8f41 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -178,13 +178,19 @@ App-session allocation strings remain **human-readable decimal strings** such as ### Session Key Operations +Both `state.user_sig` (wallet authorization) and `state.session_key_sig` (proof of +possession by the session-key holder) are required at submit time. The `*Ownership` +helpers produce `session_key_sig`. + | Method | Description | |---|---| -| `signChannelSessionKeyState(state)` | Sign a channel session-key state payload | -| `submitChannelSessionKeyState(state)` | Register/submit a channel session-key state | +| `signChannelSessionKeyState(state)` | Wallet `user_sig` over channel session-key state | +| `signChannelSessionKeyOwnership(state, sessionKeySigner)` | Session-key holder's `session_key_sig` for channel state | +| `submitChannelSessionKeyState(state)` | Register/submit a channel session-key state (both sigs required) | | `getLastChannelKeyStates(userAddress, sessionKey?)` | Fetch channel session-key states for wallet/key | -| `signSessionKeyState(state)` | Sign an app-session key state payload | -| `submitSessionKeyState(state)` | Register/submit an app-session key state | +| `signSessionKeyState(state)` | Wallet `user_sig` over app session-key state | +| `signAppSessionKeyOwnership(state, sessionKeySigner)` | Session-key holder's `session_key_sig` for app state | +| `submitSessionKeyState(state)` | Register/submit an app-session key state (both sigs required) | | `getLastKeyStates(userAddress, sessionKey?)` | Fetch app-session key states for wallet/key | ### Transfers diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index f4ba1cfc5..54e891c6c 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -2,6 +2,7 @@ import { Client, ChannelDefaultSigner, ChannelSessionKeyStateSigner, + type EthereumMsgSigner, type StateSigner, type TransactionSigner, } from '@yellow-org/sdk'; @@ -859,6 +860,13 @@ export class NitroliteClient { return this.innerClient.signChannelSessionKeyState(state); } + async signChannelSessionKeyOwnership( + state: ChannelSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner, + ): Promise { + return this.innerClient.signChannelSessionKeyOwnership(state, sessionKeySigner); + } + async submitChannelSessionKeyState(state: ChannelSessionKeyStateV1): Promise { await this.innerClient.submitChannelSessionKeyState(state); } @@ -874,6 +882,13 @@ export class NitroliteClient { return this.innerClient.signSessionKeyState(state); } + async signAppSessionKeyOwnership( + state: AppSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner, + ): Promise { + return this.innerClient.signAppSessionKeyOwnership(state, sessionKeySigner); + } + async submitSessionKeyState(state: AppSessionKeyStateV1): Promise { await this.innerClient.submitSessionKeyState(state); } diff --git a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap index 1b4a6dd19..d43aa0499 100644 --- a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap +++ b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -567,6 +567,8 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "resolveAsset: (symbol: string): Promise", "resolveAssetDisplay: (tokenAddress: Address | string, _chainId?: number): Promise<{ symbol: string; decimals: number; } | null>", "resolveToken: (tokenAddress: Address | string): Promise", + "signAppSessionKeyOwnership: (state: AppSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", + "signChannelSessionKeyOwnership: (state: ChannelSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", "signSessionKeyState: (state: AppSessionKeyStateV1): Promise", "submitAppState: (params: SubmitAppStateRequestParams): Promise<{ appSessionId: string; version: number; status: string; }>", diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts index 1e8ccccbb..12f88e152 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -197,9 +197,11 @@ describe('compat public runtime API drift guard', () => { expect(client?.properties).toEqual( expect.arrayContaining([ expect.stringContaining('signChannelSessionKeyState:'), + expect.stringContaining('signChannelSessionKeyOwnership:'), expect.stringContaining('submitChannelSessionKeyState:'), expect.stringContaining('getLastChannelKeyStates:'), expect.stringContaining('signSessionKeyState:'), + expect.stringContaining('signAppSessionKeyOwnership:'), expect.stringContaining('submitSessionKeyState:'), expect.stringContaining('getLastKeyStates:'), ]) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index b43cfd078..b3f744362 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -78,16 +78,18 @@ client.rebalanceAppSessions(signedUpdates) // Atomic rebala ### App Session Keys ```typescript -client.signSessionKeyState(state) // Sign app session key state -client.submitSessionKeyState(state) // Register/update app session key -client.getLastKeyStates(userAddress, sessionKey?) // Get active app session key states +client.signSessionKeyState(state) // Wallet user_sig over app session key state +client.signAppSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's session_key_sig +client.submitSessionKeyState(state) // Register/update app session key (both sigs required) +client.getLastKeyStates(userAddress, sessionKey?) // Get active app session key states ``` ### Channel Session Keys ```typescript -client.signChannelSessionKeyState(state) // Sign channel session key state -client.submitChannelSessionKeyState(state) // Register/update channel session key -client.getLastChannelKeyStates(userAddress, sessionKey?) // Get active channel session key states +client.signChannelSessionKeyState(state) // Wallet user_sig over channel session key state +client.signChannelSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's session_key_sig +client.submitChannelSessionKeyState(state) // Register/update channel session key (both sigs required) +client.getLastChannelKeyStates(userAddress, sessionKey?) // Get active channel session key states ``` ### Shared Utilities @@ -492,27 +494,29 @@ const sessionKeySigner = new AppSessionKeySignerV1(sessionKeyMsgSigner); ### App Session Keys +Registration requires two signatures: the wallet's `user_sig` (authorizing the +delegation) and the session-key holder's `session_key_sig` (proving possession of the key +being registered). The node rejects submits that lack a valid `session_key_sig`. + ```typescript -// Sign and submit an app session key state -const sig = await client.signSessionKeyState({ +// sessionKeyHolder is an EthereumMsgSigner whose address equals state.session_key. +// Use a raw message signer (not a wrapped StateSigner) — the node expects a +// raw 65-byte EIP-191 signature for session_key_sig. +const sessionKeyHolder = new EthereumMsgSigner(sessionKeyPrivateKey); +const state = { user_address: '0x1234...', session_key: '0xabcd...', version: '1', application_ids: ['app1'], app_session_ids: [], expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: '0x', -}); + user_sig: '', + session_key_sig: '', +}; +state.user_sig = await client.signSessionKeyState(state); +state.session_key_sig = await client.signAppSessionKeyOwnership(state, sessionKeyHolder); -await client.submitSessionKeyState({ - user_address: '0x1234...', - session_key: '0xabcd...', - version: '1', - application_ids: ['app1'], - app_session_ids: [], - expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: sig, -}); +await client.submitSessionKeyState(state); // Query active app session key states const states = await client.getLastKeyStates('0x1234...'); @@ -522,24 +526,23 @@ const filtered = await client.getLastKeyStates('0x1234...', '0xSessionKey...'); ### Channel Session Keys ```typescript -// Sign and submit a channel session key state -const sig = await client.signChannelSessionKeyState({ +// sessionKeyHolder is an EthereumMsgSigner whose address equals state.session_key. +// Use a raw message signer (not a wrapped StateSigner) — the node expects a +// raw 65-byte EIP-191 signature for session_key_sig. +const sessionKeyHolder = new EthereumMsgSigner(sessionKeyPrivateKey); +const state = { user_address: '0x1234...', session_key: '0xabcd...', version: '1', assets: ['usdc'], expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: '0x', -}); + user_sig: '', + session_key_sig: '', +}; +state.user_sig = await client.signChannelSessionKeyState(state); +state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeyHolder); -await client.submitChannelSessionKeyState({ - user_address: '0x1234...', - session_key: '0xabcd...', - version: '1', - assets: ['usdc'], - expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: sig, -}); +await client.submitChannelSessionKeyState(state); // Query active channel session key states const states = await client.getLastChannelKeyStates('0x1234...'); diff --git a/sdk/ts/examples/app_sessions/lifecycle.ts b/sdk/ts/examples/app_sessions/lifecycle.ts index bcda8c570..777e020f1 100644 --- a/sdk/ts/examples/app_sessions/lifecycle.ts +++ b/sdk/ts/examples/app_sessions/lifecycle.ts @@ -167,12 +167,18 @@ async function main() { app_session_ids: [], expires_at: String(expiresAt), user_sig: '', + session_key_sig: '', }; const packedAppSessionKey3State = packAppSessionKeyStateV1(appSessionKey3State); + + // Wallet's user_sig authorizes the delegation. const wallet3MsgSigner = new EthereumMsgSigner(wallet3PrivateKey); - const appSessionKey3StateSig = await wallet3MsgSigner.signMessage(packedAppSessionKey3State); - appSessionKey3State.user_sig = appSessionKey3StateSig; + appSessionKey3State.user_sig = await wallet3MsgSigner.signMessage(packedAppSessionKey3State); + + // Session-key holder's session_key_sig proves possession of the key being registered. + // Both signatures are required at submit time. + appSessionKey3State.session_key_sig = await sessionKey3MsgSigner.signMessage(packedAppSessionKey3State); await wallet3Client.submitSessionKeyState(appSessionKey3State); diff --git a/sdk/ts/examples/example-app/README.md b/sdk/ts/examples/example-app/README.md index 7d8198c3d..9d41b6480 100644 --- a/sdk/ts/examples/example-app/README.md +++ b/sdk/ts/examples/example-app/README.md @@ -232,6 +232,7 @@ Session keys let your app sign state updates automatically without wallet popups import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { ChannelSessionKeyStateSigner, + EthereumMsgSigner, getChannelSessionKeyAuthMetadataHashV1, } from '@layer-3/nitrolite'; @@ -253,14 +254,19 @@ const state = { assets: ['usdc', 'weth'], expires_at: expiresAt.toString(), user_sig: '', + session_key_sig: '', }; -// 4. Sign with the main wallet and submit +// 4. Sign ownership with the session key, then user_sig with the main wallet, then submit. +// Both signatures are required; the server rejects submits with either missing. +const sessionKeySigner = new EthereumMsgSigner(privateKey); +state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeySigner); state.user_sig = await client.signChannelSessionKeyState(state); await client.submitChannelSessionKeyState(state); // 5. Compute the metadata hash const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + address, version, ['usdc', 'weth'], expiresAt, @@ -290,7 +296,7 @@ Now `sessionClient` signs off-chain state updates with the session key — no wa Submit a new version with empty assets to revoke a session key: ```ts -import { packChannelKeyStateV1 } from '@layer-3/nitrolite'; +import { EthereumMsgSigner, packChannelKeyStateV1 } from '@layer-3/nitrolite'; const existing = await client.getLastChannelKeyStates(address, sessionKeyAddress); const latest = existing[0]; @@ -302,10 +308,17 @@ const revokeState = { assets: [], expires_at: latest.expires_at, user_sig: '', + session_key_sig: '', }; +// Revoke still requires the session-key holder's possession proof — sign with the session key +// (the holder is consenting to retire it) before submit. +const sessionKeySigner = new EthereumMsgSigner(sessionKeyPrivateKey); +revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); + // Sign the revocation with the main wallet (EIP-191) const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + address, BigInt(revokeState.version), [], BigInt(revokeState.expires_at), diff --git a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx index e215180d8..0a1329286 100644 --- a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx +++ b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx @@ -9,6 +9,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import type { WalletClient } from 'viem'; import Decimal from 'decimal.js'; import { + EthereumMsgSigner, getChannelSessionKeyAuthMetadataHashV1, packChannelKeyStateV1, } from '@yellow-org/sdk'; @@ -24,10 +25,11 @@ import ActionModal from './ActionModal'; */ async function signSessionKeyStateWithWallet( wc: WalletClient, - state: { session_key: string; version: string; assets: string[]; expires_at: string }, + state: { user_address: string; session_key: string; version: string; assets: string[]; expires_at: string }, ): Promise<`0x${string}`> { if (!wc.account) throw new Error('Wallet client does not have an account'); const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + state.user_address as `0x${string}`, BigInt(state.version), state.assets, BigInt(state.expires_at), @@ -257,17 +259,22 @@ export default function WalletDashboard({ assets: assetList, expires_at: expiresAt.toString(), user_sig: '', + session_key_sig: '', }; - // Sign using the SDK method (goes through ChannelDefaultSigner, strips prefix) + // Wallet's user_sig authorizes the delegation (goes through ChannelDefaultSigner, strips prefix). const sig = await client.signChannelSessionKeyState(state); state.user_sig = sig; + // Session-key holder's session_key_sig proves possession of the key being registered. + const sessionKeySigner = new EthereumMsgSigner(newSk.privateKey as `0x${string}`); + state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeySigner); + // Submit to nitronode await client.submitChannelSessionKeyState(state); // Compute metadata hash for the session key signer - const metadataHash = getChannelSessionKeyAuthMetadataHashV1(version, assetList, expiresAt); + const metadataHash = getChannelSessionKeyAuthMetadataHashV1(address as `0x${string}`, version, assetList, expiresAt); const activeSk: SessionKeyState = { ...newSk, @@ -302,9 +309,13 @@ export default function WalletDashboard({ assets: [] as string[], expires_at: latest.expires_at, user_sig: '', + session_key_sig: '', }; const sig = await signSessionKeyStateWithWallet(walletClient, revokeState); revokeState.user_sig = sig; + // Session-key holder's session_key_sig is required on every submit, including revoke. + const sessionKeySigner = new EthereumMsgSigner(sessionKey.privateKey as `0x${string}`); + revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); await client.submitChannelSessionKeyState(revokeState); } } catch (revokeErr) { @@ -473,6 +484,22 @@ export default function WalletDashboard({ const revokeId = `${ks.session_key}-${ks.version}`; try { setRevokingKey(revokeId); + + // Revoke requires both user_sig and session_key_sig — the latter must come from the + // session key's private key. The example app only has that key in local state for the + // currently active session key; arbitrary keys cannot be revoked here without the + // private key and will need to expire naturally via expires_at. + const isCurrentlyActive = + sessionKey?.active && sessionKey.address.toLowerCase() === ks.session_key.toLowerCase(); + if (!isCurrentlyActive || !sessionKey?.privateKey) { + showStatus( + 'error', + 'Revoke not supported for this key', + 'session_key_sig requires the session key\'s private key, which is only available for the active local key. Other keys must expire via their expires_at.', + ); + return; + } + const newVersion = BigInt(ks.version) + 1n; const revokeState = { user_address: address, @@ -481,18 +508,17 @@ export default function WalletDashboard({ assets: [] as string[], expires_at: ks.expires_at, user_sig: '', + session_key_sig: '', }; const sig = await signSessionKeyStateWithWallet(walletClient, revokeState); revokeState.user_sig = sig; + const sessionKeySigner = new EthereumMsgSigner(sessionKey.privateKey as `0x${string}`); + revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); await client.submitChannelSessionKeyState(revokeState); showStatus('success', 'Session key revoked', `Key ${formatAddress(ks.session_key)}`); - // If revoked key is the currently active one, clear it - if (sessionKey?.active && sessionKey.address.toLowerCase() === ks.session_key.toLowerCase()) { - await onClearSessionKey(); - } - + await onClearSessionKey(); await fetchKeyStates(); } catch (error) { showStatus('error', 'Revoke failed', error instanceof Error ? error.message : String(error)); diff --git a/sdk/ts/src/app/types.ts b/sdk/ts/src/app/types.ts index 262d4ffe0..a88019038 100644 --- a/sdk/ts/src/app/types.ts +++ b/sdk/ts/src/app/types.ts @@ -176,6 +176,8 @@ export interface AppSessionKeyStateV1 { expires_at: string; /** User's signature over the session key metadata */ user_sig: string; + /** Session-key holder's signature proving possession of the key being registered */ + session_key_sig: string; } /** diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 8656ceeb5..8e2d4143b 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -39,7 +39,7 @@ import * as blockchain from './blockchain/index.js'; import { nextState, applyChannelCreation, applyAcknowledgementTransition, applyHomeDepositTransition, applyHomeWithdrawalTransition, applyTransferSendTransition, applyFinalizeTransition, applyCommitTransition } from './core/state.js'; import { newVoidState } from './core/types.js'; import { packState, packChallengeState } from './core/state_packer.js'; -import { StateSigner, TransactionSigner } from './signers.js'; +import { EthereumMsgSigner, StateSigner, TransactionSigner } from './signers.js'; /** * Default challenge period for channels (1 day in seconds) @@ -1657,13 +1657,16 @@ export class Client { /** * Sign a channel session key state using the client's state signer. * This creates a properly formatted EIP-191 signature that can be set on the state's - * user_sig field before submitting via submitChannelSessionKeyState. + * user_sig field before submitting via submitChannelSessionKeyState. The matching + * session_key_sig (see signChannelSessionKeyOwnership) must also be populated — submits + * with only one of the two are rejected. * - * @param state - The channel session key state to sign (user_sig field is excluded from signing) + * @param state - The channel session key state to sign (user_sig and session_key_sig fields are excluded from signing) * @returns The hex-encoded signature string */ async signChannelSessionKeyState(state: ChannelSessionKeyStateV1): Promise { const metadataHash = core.getChannelSessionKeyAuthMetadataHashV1( + state.user_address as Address, BigInt(state.version), state.assets, BigInt(state.expires_at) @@ -1676,9 +1679,44 @@ export class Client { return stripSignerTypePrefix(channelSig); } + /** + * Produce the session-key holder's ownership signature for a channel session key + * registration. The caller-supplied signer must hold the session key being registered; + * the returned hex string populates state.session_key_sig before submit. session_key is + * bound into the metadata hash so a signature minted for one key cannot be replayed for + * another. + * + * The parameter is narrowed to EthereumMsgSigner because the server recovers + * session_key_sig as a raw 65-byte EIP-191 signature — a broader StateSigner could wrap + * the signature with type-prefix bytes (e.g. ChannelDefaultSigner, ChannelSessionKeyStateSigner) + * that the server rejects. + * + * @param state - The channel session key state to sign (session_key_sig field is excluded) + * @param sessionKeySigner - EthereumMsgSigner whose address equals state.session_key + * @returns The hex-encoded signature string + */ + async signChannelSessionKeyOwnership( + state: ChannelSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner + ): Promise { + const metadataHash = core.getChannelSessionKeyAuthMetadataHashV1( + state.user_address as Address, + BigInt(state.version), + state.assets, + BigInt(state.expires_at) + ); + const packed = core.packChannelKeyStateV1( + state.session_key as Address, + metadataHash + ); + return await sessionKeySigner.signMessage(packed); + } + /** * Submit a channel session key state for registration or update. - * The state must be signed by the user's wallet to authorize the session key delegation. + * The state must carry both the wallet's user_sig (proving the user authorized the + * delegation) and the session-key holder's session_key_sig (proving possession of the + * key being registered). Submits without a valid session_key_sig are rejected. * * @param state - The channel session key state containing delegation information */ @@ -1720,9 +1758,11 @@ export class Client { /** * Sign an app session key state using the client's state signer. * This creates a properly formatted EIP-191 signature that can be set on the state's - * user_sig field before submitting via submitSessionKeyState. + * user_sig field before submitting via submitSessionKeyState. The matching + * session_key_sig (see signAppSessionKeyOwnership) must also be populated — submits + * with only one of the two are rejected. * - * @param state - The app session key state to sign (user_sig field is excluded from signing) + * @param state - The app session key state to sign (user_sig and session_key_sig fields are excluded from signing) * @returns The hex-encoded signature string */ async signSessionKeyState(state: app.AppSessionKeyStateV1): Promise { @@ -1731,9 +1771,34 @@ export class Client { return stripSignerTypePrefix(channelSig); } + /** + * Produce the session-key holder's ownership signature for an app session key + * registration. The caller-supplied signer must hold the session key being registered; + * the returned hex string populates state.session_key_sig before submit. The packed + * app-session state already binds user_address, so the same packed bytes are used for + * both the wallet's user_sig and the session-key holder's session_key_sig. + * + * The parameter is narrowed to EthereumMsgSigner because the server recovers + * session_key_sig as a raw 65-byte EIP-191 signature — a broader StateSigner could wrap + * the signature with type-prefix bytes (e.g. AppSessionWalletSignerV1) that the server rejects. + * + * @param state - The app session key state to sign (session_key_sig field is excluded) + * @param sessionKeySigner - EthereumMsgSigner whose address equals state.session_key + * @returns The hex-encoded signature string + */ + async signAppSessionKeyOwnership( + state: app.AppSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner + ): Promise { + const packed = app.packAppSessionKeyStateV1(state); + return await sessionKeySigner.signMessage(packed); + } + /** * Submit an app session key state for registration or update. - * The state must be signed by the user's wallet to authorize the session key delegation. + * The state must carry both the wallet's user_sig (proving the user authorized the + * delegation) and the session-key holder's session_key_sig (proving possession of the + * key being registered). Submits without a valid session_key_sig are rejected. * * @param state - The session key state containing delegation information */ diff --git a/sdk/ts/src/core/utils.ts b/sdk/ts/src/core/utils.ts index 429ec05d7..5b5d38457 100644 --- a/sdk/ts/src/core/utils.ts +++ b/sdk/ts/src/core/utils.ts @@ -407,26 +407,31 @@ function parseAccountIdToBytes32(accountId: string | undefined): `0x${string}` { } /** - * Computes the metadata hash for a channel session key authorization. - * Matches Go SDK's GetChannelSessionKeyAuthMetadataHashV1. + * Computes the metadata hash for a channel session key authorization. user_address is bound + * into the hash; together with the session_key already in packChannelKeyStateV1, this binds + * the signed payload to a single (wallet, session_key) pair so signatures cannot be replayed + * across wallets or session keys. Matches Go SDK's GetChannelSessionKeyAuthMetadataHashV1. * + * @param userAddress - The wallet address authorizing the session key * @param version - Session key state version * @param assets - Asset symbols associated with the session key * @param expiresAt - Unix timestamp in seconds when the session key expires * @returns Keccak256 hash of the ABI-encoded metadata */ export function getChannelSessionKeyAuthMetadataHashV1( + userAddress: Address, version: bigint, assets: string[], expiresAt: bigint ): `0x${string}` { const packed = encodeAbiParameters( [ + { type: 'address' }, // user_address { type: 'uint64' }, // version { type: 'string[]' }, // assets { type: 'uint64' }, // expires_at ], - [version, assets, expiresAt] + [userAddress, version, assets, expiresAt] ); return keccak256(packed); } @@ -451,3 +456,4 @@ export function packChannelKeyStateV1( [sessionKey, metadataHash] ); } + diff --git a/sdk/ts/src/rpc/types.ts b/sdk/ts/src/rpc/types.ts index 0fbb8453e..549be8f17 100644 --- a/sdk/ts/src/rpc/types.ts +++ b/sdk/ts/src/rpc/types.ts @@ -145,6 +145,8 @@ export interface ChannelSessionKeyStateV1 { expires_at: string; /** User's signature over the session key metadata */ user_sig: string; + /** Session-key holder's signature proving possession of the key being registered */ + session_key_sig: string; } // ============================================================================ diff --git a/sdk/ts/src/session_key_state_transforms.ts b/sdk/ts/src/session_key_state_transforms.ts index 2a1a9fe3a..f3a16f804 100644 --- a/sdk/ts/src/session_key_state_transforms.ts +++ b/sdk/ts/src/session_key_state_transforms.ts @@ -37,6 +37,7 @@ export function transformChannelSessionKeyState( assets: requireStringArrayField(raw, context, 'assets'), expires_at: requireStringField(raw, context, 'expires_at'), user_sig: requireStringField(raw, context, 'user_sig'), + session_key_sig: requireStringField(raw, context, 'session_key_sig'), }; } @@ -52,5 +53,6 @@ export function transformAppSessionKeyState( app_session_ids: requireStringArrayField(raw, context, 'app_session_ids'), expires_at: requireStringField(raw, context, 'expires_at'), user_sig: requireStringField(raw, context, 'user_sig'), + session_key_sig: requireStringField(raw, context, 'session_key_sig'), }; } diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index 60e898137..795f91731 100644 --- a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap +++ b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -218,6 +218,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "application_ids: string[]", "expires_at: string", "session_key: string", + "session_key_sig: string", "user_address: string", "user_sig: string", "version: string", @@ -783,6 +784,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "assets: string[]", "expires_at: string", "session_key: string", + "session_key_sig: string", "user_address: string", "user_sig: string", "version: string", @@ -1068,6 +1070,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "rebalanceAppSessions: (signedUpdates: app.SignedAppStateUpdateV1[]): Promise", "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", "setHomeBlockchain: (asset: string, blockchainId: bigint): Promise", + "signAppSessionKeyOwnership: (state: app.AppSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", + "signChannelSessionKeyOwnership: (state: ChannelSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", "signSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", "signState: (state: core.State): Promise", @@ -1356,7 +1360,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "function", "name": "getChannelSessionKeyAuthMetadataHashV1", "signatures": [ - "(version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", + "(userAddress: Address, version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", ], }, { diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts index 9dcba41a1..120821a56 100644 --- a/sdk/ts/test/unit/transform-drift.test.ts +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -51,6 +51,7 @@ const channelKeyStateRaw = { assets: ['YUSD'], expires_at: '1739812234', user_sig: '0xabc123', + session_key_sig: '0xabc124', }; const appSessionKeyStateRaw = { @@ -61,6 +62,7 @@ const appSessionKeyStateRaw = { app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], expires_at: '1739812234', user_sig: '0xdef456', + session_key_sig: '0xdef457', }; describe('Nitronode response transform drift guards', () => { @@ -227,6 +229,13 @@ describe('Nitronode response transform drift guards', () => { expect(transformAppSessionKeyState(appSessionKeyStateRaw)).toEqual(appSessionKeyStateRaw); }); + it('accepts empty session_key_sig for rows written before the column existed', () => { + const legacyChannel = { ...channelKeyStateRaw, session_key_sig: '' }; + const legacyApp = { ...appSessionKeyStateRaw, session_key_sig: '' }; + expect(transformChannelSessionKeyState(legacyChannel)).toEqual(legacyChannel); + expect(transformAppSessionKeyState(legacyApp)).toEqual(legacyApp); + }); + it('rejects malformed key-state fixtures with clear errors', () => { expect(() => transformChannelSessionKeyState(