From 3143e406d836d16ef908de48809f9155952a57fc Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 13:34:42 +0300 Subject: [PATCH 1/4] YNU-918: feat(nitronode/api): treat past expires_at as session-key revocation Submit handlers for channels.v1.submit_session_key_state and app_sessions.v1.submit_session_key_state previously rejected any state whose expires_at was not strictly in the future. There was no first-class way for a user to deactivate a session key before its natural expiry. Accept past expires_at as a revocation: the same monotonic version sequence is preserved, and the auth path (GetAppSessionKeyOwner / ValidateChannelSessionKeyForAsset) already filters expires_at > now, so a past timestamp deactivates the key immediately. A later submit with the next version and a future expires_at re-activates the same session key address. Reject expires_at < 0 instead of expires_at <= now. Defense-in-depth: the metadata-hash packer casts int64 -> uint64, which would wrap a negative unix timestamp to a huge future value and silently desynchronize the user-signed payload from the value persisted in the database (the DB filter is still the source of truth, but the divergence is undesirable). Update CountSessionKeysForUser to JOIN both history tables and only count rows where expires_at > now, so a revoke frees the per-user cap slot. A single now is bound for both kind branches for internal consistency. Emit an Info log "session key revoked" / "channel session key revoked" when the submission deactivates the key, distinct from the existing "successfully stored" log. docs/api.yaml, pkg/rpc/types.go, and the Go SDK Submit* doc comments are refreshed to describe revoke and re-activation semantics. --- docs/api.yaml | 26 ++++++- .../submit_session_key_state.go | 19 ++++- .../submit_session_key_state_test.go | 45 ++++++++++- .../channel_v1/submit_session_key_state.go | 19 ++++- .../submit_session_key_state_test.go | 47 ++++++++++- .../database/current_session_key_state.go | 33 +++++--- .../current_session_key_state_test.go | 77 +++++++++++++++++++ pkg/rpc/types.go | 8 +- sdk/go/app_session.go | 15 +++- sdk/go/channel.go | 15 +++- 10 files changed, 271 insertions(+), 33 deletions(-) diff --git a/docs/api.yaml b/docs/api.yaml index 8fa6b21e2..548592265 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -648,11 +648,20 @@ api: - message: denied_until_checkpoint description: State submissions are denied until the next checkpoint - name: submit_session_key_state - description: Submit the session key state for registration and updates + description: | + Submit the channel session key state for registration, update, revocation, or re-activation. + + The `expires_at` timestamp on `state` governs activation: + * A value in the future activates the session key until that time. + * A value at or before "now" revokes the key immediately. The same monotonic + `version` sequence is preserved, so a subsequent submit with `version+1` and + a future `expires_at` re-activates the same session key address. + The metadata hash binds `expires_at`, so each revoke or re-activation requires a + fresh user signature over the new payload. Negative unix timestamps are rejected. request: - field_name: state type: channel_session_key_state - description: Session key metadata and delegation information + description: Session key metadata and delegation information. Set `expires_at` to a past value to revoke; future to (re-)activate. response: [] errors: - message: invalid_session_key_state @@ -846,11 +855,20 @@ api: - message: insufficient_balance description: Participant has insufficient balance for allocations - name: submit_session_key_state - description: Submit the session key state for registration and updates + description: | + Submit the app session key state for registration, update, revocation, or re-activation. + + The `expires_at` timestamp on `state` governs activation: + * A value in the future activates the session key until that time. + * A value at or before "now" revokes the key immediately. The same monotonic + `version` sequence is preserved, so a subsequent submit with `version+1` and + a future `expires_at` re-activates the same session key address. + The metadata hash binds `expires_at`, so each revoke or re-activation requires a + fresh user signature over the new payload. Negative unix timestamps are rejected. request: - field_name: state type: app_session_key_state - description: Session key metadata and delegation information + description: Session key metadata and delegation information. Set `expires_at` to a past value to revoke; future to (re-)activate. response: [] errors: - message: invalid_session_key_state 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 a10b676af..bcbf7e38b 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -57,8 +57,15 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return } - if coreState.ExpiresAt.Before(time.Now()) { - c.Fail(rpc.Errorf("invalid_session_key_state: expires_at must be in the future"), "") + // Past expires_at is permitted as a revocation signal. The auth path filters + // expires_at > now so a past timestamp deactivates the key immediately while keeping + // the same monotonic version sequence (a later submit with a future expires_at can + // re-activate the key). A negative unix timestamp is rejected because the + // metadata-hash packer casts int64 -> uint64 (wraps to a huge future value), which + // would cause the user-signed payload to disagree with the value persisted in the + // database — defense-in-depth even though the DB filter is the source of truth. + if coreState.ExpiresAt.Unix() < 0 { + c.Fail(rpc.Errorf("invalid_session_key_state: expires_at must be non-negative"), "") return } if len(coreState.AppSessionIDs) > h.maxSessionKeyIDs { @@ -155,6 +162,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) + if !coreState.ExpiresAt.After(time.Now()) { + logger.Info("session key revoked", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "version", coreState.Version, + "expiresAt", coreState.ExpiresAt) + return + } logger.Info("successfully stored session key state", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, 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 450f63b27..b4870ffb4 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 @@ -292,7 +292,45 @@ func TestSubmitSessionKeyState_InvalidUserAddress(t *testing.T) { assert.Contains(t, respErr.Error(), "invalid user_address") } -func TestSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { +func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(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, + } + + // expires_at in the past expresses a revoke: the same monotonic version sequence + // is preserved, the auth path filters expires_at > now so the key is deactivated. + expiresAt := time.Now().Add(-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, nil) + mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + +func TestSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { mockStore := new(MockStore) handler := &Handler{ useStoreInTx: func(handler StoreTxHandler) error { @@ -309,7 +347,7 @@ func TestSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { Version: "1", ApplicationIDs: []string{}, AppSessionIDs: []string{}, - ExpiresAt: strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10), + ExpiresAt: "-1", UserSig: "0xdeadbeef", }, } @@ -327,7 +365,8 @@ func TestSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "expires_at must be in the future") + assert.Contains(t, respErr.Error(), "expires_at must be non-negative") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } func TestSubmitSessionKeyState_MissingUserSig(t *testing.T) { diff --git a/nitronode/api/channel_v1/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index c3c9f8c40..1255753cc 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -56,8 +56,15 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return } - if coreState.ExpiresAt.Before(time.Now()) { - c.Fail(rpc.Errorf("invalid_session_key_state: expires_at must be in the future"), "") + // Past expires_at is permitted as a revocation signal. The auth path filters + // expires_at > now so a past timestamp deactivates the key immediately while keeping + // the same monotonic version sequence (a later submit with a future expires_at can + // re-activate the key). A negative unix timestamp is rejected because the + // metadata-hash packer casts int64 -> uint64 (wraps to a huge future value), which + // would cause the user-signed payload to disagree with the value persisted in the + // database — defense-in-depth even though the DB filter is the source of truth. + if coreState.ExpiresAt.Unix() < 0 { + c.Fail(rpc.Errorf("invalid_session_key_state: expires_at must be non-negative"), "") return } if len(coreState.Assets) > h.maxSessionKeyIDs { @@ -138,6 +145,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) + if !coreState.ExpiresAt.After(time.Now()) { + logger.Info("channel session key revoked", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "version", coreState.Version, + "expiresAt", coreState.ExpiresAt) + return + } logger.Info("successfully stored channel session key state", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, 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 c09706ba0..001332d7f 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -212,7 +212,47 @@ func TestChannelSubmitSessionKeyState_InvalidUserAddress(t *testing.T) { assert.Contains(t, respErr.Error(), "invalid user_address") } -func TestChannelSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { +func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(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, + } + + // expires_at in the past expresses a revoke: the same monotonic version sequence + // is preserved, the auth path filters expires_at > now so the key is deactivated. + expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) + assets := []string{"USDC"} + + 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) + + 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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + +func TestChannelSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { mockStore := new(MockStore) handler := &Handler{ useStoreInTx: func(handler StoreTxHandler) error { @@ -228,7 +268,7 @@ func TestChannelSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { SessionKey: "0x3333333333333333333333333333333333333333", Version: "1", Assets: []string{}, - ExpiresAt: strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10), + ExpiresAt: "-1", UserSig: "0xdeadbeef", }, } @@ -246,7 +286,8 @@ func TestChannelSubmitSessionKeyState_ExpiredExpiresAt(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "expires_at must be in the future") + assert.Contains(t, respErr.Error(), "expires_at must be non-negative") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } func TestChannelSubmitSessionKeyState_MissingUserSig(t *testing.T) { diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 2fd124b32..038649837 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -136,19 +136,34 @@ func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind Sessi return locked.Version, nil } -// CountSessionKeysForUser returns the number of distinct session keys recorded for the wallet -// in the pointer table, across both kinds. Drives the per-user cap at submit time. +// CountSessionKeysForUser returns the number of distinct active session keys recorded for the +// wallet in the pointer table, across both kinds. Drives the per-user cap at submit time. // Rows seeded by LockSessionKeyState (version=0) are excluded so that a failed-cap rejection -// does not itself leave a phantom row counted toward the cap. +// does not itself leave a phantom row counted toward the cap. Revoked or naturally expired +// keys (expires_at <= now in the underlying history row) are also excluded so that a revoke +// frees the slot. A single now is bound for both kind branches so the count is internally +// consistent. func (s *DBStore) CountSessionKeysForUser(userAddress string) (uint32, error) { userAddress = strings.ToLower(userAddress) + now := time.Now().UTC() - var count int64 - err := s.db.Model(&CurrentSessionKeyStateV1{}). - Where("user_address = ? AND version > 0", userAddress). - Count(&count).Error + var channelCount int64 + err := s.db.Table("current_session_key_states_v1 AS c"). + Joins("JOIN channel_session_key_states_v1 h ON h.user_address = c.user_address AND h.session_key = c.session_key AND h.version = c.version"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0 AND h.expires_at > ?", userAddress, SessionKeyKindChannel, now). + Count(&channelCount).Error if err != nil { - return 0, fmt.Errorf("failed to count session keys for user: %w", err) + return 0, fmt.Errorf("failed to count channel session keys for user: %w", err) } - return uint32(count), nil + + var appCount int64 + err = s.db.Table("current_session_key_states_v1 AS c"). + Joins("JOIN app_session_key_states_v1 h ON h.user_address = c.user_address AND h.session_key = c.session_key AND h.version = c.version"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0 AND h.expires_at > ?", userAddress, SessionKeyKindAppSession, now). + Count(&appCount).Error + if err != nil { + return 0, fmt.Errorf("failed to count app session keys for user: %w", err) + } + + return uint32(channelCount + appCount), nil } diff --git a/nitronode/store/database/current_session_key_state_test.go b/nitronode/store/database/current_session_key_state_test.go index 91d15cb9e..85830a32b 100644 --- a/nitronode/store/database/current_session_key_state_test.go +++ b/nitronode/store/database/current_session_key_state_test.go @@ -240,6 +240,83 @@ func TestDBStore_CountSessionKeysForUser(t *testing.T) { require.NoError(t, err) assert.Equal(t, uint32(0), count) }) + + t.Run("Revoked or expired keys do not count against the cap", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // Active app-session key. + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + + // Revoked app-session key (submit at the next version with expires_at in the past). + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyB, + Version: 1, + ExpiresAt: time.Now().Add(-time.Hour), + UserSig: "0xsig", + })) + + // Active channel key for the same user. + require.NoError(t, store.StoreChannelSessionKeyState(core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + + // Revoked channel key. + require.NoError(t, store.StoreChannelSessionKeyState(core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyB, + Version: 1, + ExpiresAt: time.Now().Add(-time.Hour), + UserSig: "0xsig", + })) + + count, err := store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(2), count, "only active keys (1 app + 1 channel) should count") + }) + + t.Run("Rotating an existing key out via past expires_at frees the slot", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig_active", + })) + + count, err := store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(1), count) + + // Submit version 2 with past expires_at as a revoke. + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 2, + ExpiresAt: time.Now().Add(-time.Hour), + UserSig: "0xsig_revoke", + })) + + count, err = store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(0), count, "revoke must free the slot") + }) } func TestDBStore_CurrentPointer_VersionMonotonic(t *testing.T) { diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go index 02ea60ea3..0e309824a 100644 --- a/pkg/rpc/types.go +++ b/pkg/rpc/types.go @@ -67,7 +67,9 @@ type ChannelSessionKeyStateV1 struct { Version string `json:"version"` // Assets associated with this session key Assets []string `json:"assets"` - // Expiration time as unix timestamp of this session key + // ExpiresAt is the unix timestamp (seconds) at which the session key stops + // authorizing requests. A future value activates the key; a value at or before + // the current time revokes it immediately. Negative values are rejected. 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"` @@ -212,7 +214,9 @@ type AppSessionKeyStateV1 struct { ApplicationIDs []string `json:"application_ids"` // AppSessionID is the application session IDs associated with this session key AppSessionIDs []string `json:"app_session_ids"` - // ExpiresAt is Unix timestamp in seconds indicating when the session key expires + // ExpiresAt is the unix timestamp (seconds) at which the session key stops + // authorizing requests. A future value activates the key; a value at or before + // the current time revokes it immediately. Negative values are rejected. 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"` diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index 312319732..5209fe975 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -290,10 +290,17 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // Session Key Methods // ============================================================================ -// SubmitAppSessionKeyState submits a session key state for registration or update. -// 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. +// SubmitAppSessionKeyState submits a session key state for registration, update, +// revocation, or re-activation. The state must carry both the wallet's UserSig +// (authorizing the delegation) and the session-key holder's SessionKeySig (proving +// possession of the key); submits without a valid SessionKeySig are rejected. +// +// Set state.ExpiresAt to a future time to register or update the key. Set it to a +// value at or before time.Now() to revoke the key — the auth path filters by +// expires_at, so the key is deactivated immediately while keeping the same monotonic +// version sequence. A later submit with the next version and a future ExpiresAt +// re-activates the same session key address. Negative unix timestamps are rejected +// by the server. // // Parameters: // - state: The session key state containing delegation information diff --git a/sdk/go/channel.go b/sdk/go/channel.go index e4ad27828..9f82c8f5e 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -902,10 +902,17 @@ type GetLastChannelKeyStatesOptions struct { IncludeInactive *bool } -// SubmitChannelSessionKeyState submits a channel session key state for registration or update. -// 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. +// SubmitChannelSessionKeyState submits a channel session key state for registration, +// update, revocation, or re-activation. The state must carry both the wallet's UserSig +// (authorizing the delegation) and the session-key holder's SessionKeySig (proving +// possession of the key); submits without a valid SessionKeySig are rejected. +// +// Set state.ExpiresAt to a future time to register or update the key. Set it to a +// value at or before time.Now() to revoke the key — the auth path filters by +// expires_at, so the key is deactivated immediately while keeping the same monotonic +// version sequence. A later submit with the next version and a future ExpiresAt +// re-activates the same session key address. Negative unix timestamps are rejected +// by the server. // // Parameters: // - state: The channel session key state containing delegation information From f826dd871501e081824b0f0a74ba924af29de43a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 20 May 2026 12:02:15 +0300 Subject: [PATCH 2/4] YNU-918 review fixups: cap-count overflow guard + revoke/reactivate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CountSessionKeysForUser now bounds-checks the int64 sum before casting to uint32 and returns an explicit error if the total ever exceeds math.MaxUint32. Defense against silent wrap that would let the cap comparison pass incorrectly under the soft-cap concurrency race noted in TODO(MF-H01-followup). - Add TestSubmitSessionKeyState_RevokeExistingActiveKey and TestSubmitSessionKeyState_ReactivateAfterRevoke for both the app-session and channel handlers. These cover the typical revocation path (latestVersion > 0, past expires_at) and the re-activation round-trip (latestVersion > 0, future expires_at) — the prior tests only exercised the new-key-with-past-expiry path. Both new tests assert CountSessionKeysForUser is not called, locking in the short-circuit on the latestVersion > 0 branch. - Document explicitly in docs/api.yaml and the Go SDK Submit* doc comments that session_key_sig is required on every submit, including the revoke path. Wallet-only revocation for a lost or compromised key is not supported by this method and is tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api.yaml | 12 +++ .../submit_session_key_state_test.go | 82 ++++++++++++++++++ .../submit_session_key_state_test.go | 86 +++++++++++++++++++ .../database/current_session_key_state.go | 7 +- sdk/go/app_session.go | 5 +- sdk/go/channel.go | 5 +- 6 files changed, 194 insertions(+), 3 deletions(-) diff --git a/docs/api.yaml b/docs/api.yaml index 548592265..25419a586 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -658,6 +658,12 @@ api: a future `expires_at` re-activates the same session key address. The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. + + Both `user_sig` (wallet) and `session_key_sig` (session-key holder) are required + on every submit, including the revocation path — the session key must co-sign its + own deactivation. Wallet-only revocation (for a lost or compromised key) is not + supported by this method; that flow requires a separate code path and is tracked + as a follow-up. request: - field_name: state type: channel_session_key_state @@ -865,6 +871,12 @@ api: a future `expires_at` re-activates the same session key address. The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. + + Both `user_sig` (wallet) and `session_key_sig` (session-key holder) are required + on every submit, including the revocation path — the session key must co-sign its + own deactivation. Wallet-only revocation (for a lost or compromised key) is not + supported by this method; that flow requires a separate code path and is tracked + as a follow-up. request: - field_name: state type: app_session_key_state 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 b4870ffb4..19a47f828 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 @@ -330,6 +330,88 @@ func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { mockStore.AssertExpectations(t) } +// Covers the typical revocation path: an active key (latestVersion > 0) is deactivated +// by submitting version+1 with a past expires_at. The per-user cap check is short-circuited +// because the key already exists, so CountSessionKeysForUser must not be called. +func TestSubmitSessionKeyState_RevokeExistingActiveKey(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, + maxSessionKeysPerUser: 5, + } + + expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(1, nil) + mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + +// Covers the re-activation path: after a revoke (latestVersion incremented twice — register +// then revoke), submitting version+1 with a future expires_at re-activates the same session +// key address without re-running the per-user cap check. +func TestSubmitSessionKeyState_ReactivateAfterRevoke(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, + maxSessionKeysPerUser: 5, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(2, nil) + mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + func TestSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { mockStore := new(MockStore) handler := &Handler{ 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 001332d7f..edb6f3d3f 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -252,6 +252,92 @@ func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { mockStore.AssertExpectations(t) } +// Covers the typical revocation path: an active key (latestVersion > 0) is deactivated +// by submitting version+1 with a past expires_at. The per-user cap check is short-circuited +// because the key already exists, so CountSessionKeysForUser must not be called. +func TestChannelSubmitSessionKeyState_RevokeExistingActiveKey(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, + maxSessionKeysPerUser: 5, + } + + expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) + assets := []string{"USDC"} + + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, assets, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(1, nil) + mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + +// Covers the re-activation path: after a revoke (latestVersion incremented twice — register +// then revoke), submitting version+1 with a future expires_at re-activates the same session +// key address without re-running the per-user cap check. +func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke(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, + maxSessionKeysPerUser: 5, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + assets := []string{"USDC"} + + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, assets, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(2, nil) + mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + func TestChannelSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { mockStore := new(MockStore) handler := &Handler{ diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 038649837..49d8e1dad 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -3,6 +3,7 @@ package database import ( "errors" "fmt" + "math" "strings" "time" @@ -165,5 +166,9 @@ func (s *DBStore) CountSessionKeysForUser(userAddress string) (uint32, error) { return 0, fmt.Errorf("failed to count app session keys for user: %w", err) } - return uint32(channelCount + appCount), nil + total := channelCount + appCount + if total < 0 || total > math.MaxUint32 { + return 0, fmt.Errorf("session key count %d out of uint32 range", total) + } + return uint32(total), nil } diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index 5209fe975..b88d3e82a 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -293,7 +293,10 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // SubmitAppSessionKeyState submits a session key state for registration, update, // revocation, or re-activation. The state must carry both the wallet's UserSig // (authorizing the delegation) and the session-key holder's SessionKeySig (proving -// possession of the key); submits without a valid SessionKeySig are rejected. +// possession of the key); submits without a valid SessionKeySig are rejected on every +// path, including revocation — the session key must co-sign its own deactivation. +// Wallet-only revocation (for a lost or compromised key) is not supported by this +// method. // // Set state.ExpiresAt to a future time to register or update the key. Set it to a // value at or before time.Now() to revoke the key — the auth path filters by diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 9f82c8f5e..e05fa0116 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -905,7 +905,10 @@ type GetLastChannelKeyStatesOptions struct { // SubmitChannelSessionKeyState submits a channel session key state for registration, // update, revocation, or re-activation. The state must carry both the wallet's UserSig // (authorizing the delegation) and the session-key holder's SessionKeySig (proving -// possession of the key); submits without a valid SessionKeySig are rejected. +// possession of the key); submits without a valid SessionKeySig are rejected on every +// path, including revocation — the session key must co-sign its own deactivation. +// Wallet-only revocation (for a lost or compromised key) is not supported by this +// method. // // Set state.ExpiresAt to a future time to register or update the key. Set it to a // value at or before time.Now() to revoke the key — the auth path filters by From f6e7f34c227ae58330ea94540f6040d2d1cb019e Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Thu, 21 May 2026 13:18:12 +0300 Subject: [PATCH 3/4] =?UTF-8?q?YNU-918=20review=20fixups:=20gate=20cap=20c?= =?UTF-8?q?heck=20on=20inactive=E2=86=92active=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend LockSessionKeyState to also return the latest history expires_at so submit handlers can tell rotation from reactivation. The cap check now fires whenever the submit moves the slot from inactive to active (new key or revoked → active), closing the revoke→register-new→reactivate bypass that the prior `latestVersion == 0` gate allowed. Docs and example app updated to describe submit-as-revoke (set past expires_at, keep assets) under the new semantics. --- docs/api.yaml | 8 +- nitronode/api/app_session_v1/README.md | 11 ++- nitronode/api/app_session_v1/interface.go | 4 +- .../submit_session_key_state.go | 22 +++-- .../submit_session_key_state_test.go | 84 ++++++++++++++---- nitronode/api/app_session_v1/testing.go | 8 +- nitronode/api/channel_v1/interface.go | 9 +- .../channel_v1/submit_session_key_state.go | 22 +++-- .../submit_session_key_state_test.go | 86 +++++++++++++++---- nitronode/api/channel_v1/testing.go | 8 +- .../database/current_session_key_state.go | 59 +++++++++++-- .../current_session_key_state_test.go | 64 +++++++++++--- nitronode/store/database/interface.go | 10 ++- pkg/rpc/api.go | 10 ++- sdk/ts/examples/example-app/README.md | 13 ++- .../src/components/WalletDashboard.tsx | 8 +- sdk/ts/src/client.ts | 20 ++++- 17 files changed, 355 insertions(+), 91 deletions(-) diff --git a/docs/api.yaml b/docs/api.yaml index 25419a586..0188f4f26 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -655,7 +655,9 @@ api: * A value in the future activates the session key until that time. * A value at or before "now" revokes the key immediately. The same monotonic `version` sequence is preserved, so a subsequent submit with `version+1` and - a future `expires_at` re-activates the same session key address. + a future `expires_at` re-activates the same session key address. Re-activating + a previously-revoked key counts against the per-user cap, the same as + registering a brand-new one. The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. @@ -868,7 +870,9 @@ api: * A value in the future activates the session key until that time. * A value at or before "now" revokes the key immediately. The same monotonic `version` sequence is preserved, so a subsequent submit with `version+1` and - a future `expires_at` re-activates the same session key address. + a future `expires_at` re-activates the same session key address. Re-activating + a previously-revoked key counts against the per-user cap, the same as + registering a brand-new one. The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. diff --git a/nitronode/api/app_session_v1/README.md b/nitronode/api/app_session_v1/README.md index c027aa35d..c9a4bfca1 100644 --- a/nitronode/api/app_session_v1/README.md +++ b/nitronode/api/app_session_v1/README.md @@ -672,7 +672,12 @@ ORDER BY created_at DESC; ### 7. `app_sessions.v1.submit_session_key_state` -**Purpose**: Submits a session key state for registration or update. Session keys allow delegated signing for app sessions, enabling applications to sign on behalf of a user's wallet. +**Purpose**: Submits a session key state for registration, rotation/update, or revocation. Session keys allow delegated signing for app sessions, enabling applications to sign on behalf of a user's wallet. + +**Submit semantics**: +- **Registration**: first submit for a `(user, session_key)` pair (version=1, future `expires_at`). +- **Rotation/update**: bump version with a future `expires_at` to change scopes or extend lifetime. +- **Revocation**: bump version with `expires_at <= now`. The auth path stops accepting state signed by the key and the slot is freed against the per-user cap. **Key Features**: - Versioned session key states (each update increments the version) @@ -704,13 +709,13 @@ ORDER BY created_at DESC; - `user_address` must be a valid hex address - `session_key` must be a valid hex address - `version` must be greater than 0 -- `expires_at` must be in the future +- `expires_at` may be in the past — past values express revocation (the key is retired and the slot is freed) - `user_sig` is required - `application_ids` entries must be lowercase strings (non-lowercase values are rejected before signature verification) - `app_session_ids` entries must be lowercase strings (non-lowercase values are rejected before signature verification) - Version must be sequential (latest_version + 1) - Signature must recover to `user_address` -- Newly registered keys count against the per-user cap (`NITRONODE_MAX_SESSION_KEYS_PER_USER`, default 100). Updates to keys that already exist for the user are not blocked by the cap. +- The per-user cap (`NITRONODE_MAX_SESSION_KEYS_PER_USER`, default 100) is enforced whenever the submit transitions the slot from inactive to active: a brand-new key (no prior state) or a reactivation (previous latest state's `expires_at` was already in the past). Rotation/update against a still-active key, and revocation submits, are not subject to the cap. **Concurrency**: A `SELECT ... FOR UPDATE` is taken on a per-(user, session_key, kind) pointer row in `current_session_key_states_v1` so concurrent submits for the same key serialize and report a clean "expected version" error instead of racing on the history table's UNIQUE constraint. diff --git a/nitronode/api/app_session_v1/interface.go b/nitronode/api/app_session_v1/interface.go index 1fdbed572..e93cccb30 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -1,6 +1,8 @@ package app_session_v1 import ( + "time" + "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" @@ -53,7 +55,7 @@ type Store interface { EnsureNoOngoingEscrowOperation(wallet, asset string) error // App Session key state operations - LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) + LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (latestVersion uint64, latestExpiresAt time.Time, err error) CountSessionKeysForUser(userAddress string) (uint32, error) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint64, error) 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 bcbf7e38b..62f5cf9c8 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -104,11 +104,12 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } // Validate version and store the session key state + now := time.Now() err = h.useStoreInTx(func(tx Store) error { // Lock the (user, session_key, app_session) pointer row for the duration of the tx so // that concurrent submits for the same (user, session_key) serialize cleanly and report // a proper "expected version" error rather than racing on the history UNIQUE constraint. - latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindAppSession) + latestVersion, latestExpiresAt, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindAppSession) if err != nil { if errors.Is(err, database.ErrSessionKeyNotAllowed) { logger.Warn("session key registration collision", @@ -120,8 +121,17 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return rpc.Errorf("failed to lock session key state: %v", err) } - // Enforce the per-user cap when registering a new session key. Existing keys (latestVersion > 0) - // can always be updated regardless of the cap so that legitimate rotation is never blocked. + // Enforce the per-user cap whenever this submit transitions the slot from inactive + // to active — i.e. a brand-new key (latestVersion == 0) or a reactivation where the + // previous latest state was already past its expires_at. A rotation/update against a + // still-active key is not counted again so legitimate rotation is never blocked, and + // a revoke submit (submitted expires_at <= now) decreases the active count so it is + // not subject to the cap either. + // + // Without the reactivation check a user at the cap can revoke key A, register fresh + // key B into the freed slot, then re-submit key A with a future expires_at — the + // `latestVersion > 0` branch would skip the cap check and leave the user above the + // cap. // // TODO(MF-H01-followup): the row lock above only serializes submits for the same // (user, session_key, kind), so two concurrent submits registering *different* new keys @@ -130,7 +140,9 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // soft DOS bound, not a hard quota — a small over-shoot under genuine concurrency is // acceptable. If a hard quota is ever required, take a per-user advisory lock here // (pg_advisory_xact_lock(hashtext(user_address))) before counting. - if latestVersion == 0 && h.maxSessionKeysPerUser > 0 { + prevActive := latestVersion > 0 && latestExpiresAt.After(now) + submittedActive := coreState.ExpiresAt.After(now) + if !prevActive && submittedActive && h.maxSessionKeysPerUser > 0 { count, err := tx.CountSessionKeysForUser(coreState.UserAddress) if err != nil { return rpc.Errorf("failed to count session keys for user: %v", err) @@ -162,7 +174,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) - if !coreState.ExpiresAt.After(time.Now()) { + if !coreState.ExpiresAt.After(now) { logger.Info("session key revoked", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, 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 19a47f828..d12253961 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 @@ -91,7 +91,7 @@ func TestSubmitSessionKeyState_Success(t *testing.T) { reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, time.Time{}, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -236,7 +236,7 @@ func TestSubmitSessionKeyState_AtMaxLimit(t *testing.T) { reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, time.Time{}, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -312,7 +312,7 @@ func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { expiresAt := time.Now().Add(-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, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, time.Time{}, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -330,9 +330,10 @@ func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { mockStore.AssertExpectations(t) } -// Covers the typical revocation path: an active key (latestVersion > 0) is deactivated -// by submitting version+1 with a past expires_at. The per-user cap check is short-circuited -// because the key already exists, so CountSessionKeysForUser must not be called. +// Covers the typical revocation path: an active key (latestVersion > 0, prev expires_at in +// the future) is deactivated by submitting version+1 with a past expires_at. The per-user +// cap check is short-circuited because the previous state was already active (revoke +// decreases the active count), so CountSessionKeysForUser must not be called. func TestSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -352,7 +353,8 @@ func TestSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, nil, nil, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(1, nil) + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(1, prevActiveExpiresAt, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -371,10 +373,12 @@ func TestSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) } -// Covers the re-activation path: after a revoke (latestVersion incremented twice — register -// then revoke), submitting version+1 with a future expires_at re-activates the same session -// key address without re-running the per-user cap check. -func TestSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { +// Covers the re-activation path: after a revoke (latestVersion > 0, prev expires_at in the +// past), submitting version+1 with a future expires_at re-activates the slot — i.e. the +// active count goes from N-1 back to N. Because the previous latest state was inactive, the +// per-user cap MUST be re-checked here so a user at the cap cannot revoke→register-new→ +// reactivate to exceed it. +func TestSubmitSessionKeyState_ReactivateAfterRevoke_BelowCapAllowed(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -393,7 +397,9 @@ func TestSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, nil, nil, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(2, nil) + prevRevokedExpiresAt := time.Now().Add(-time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(2, prevRevokedExpiresAt, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(4, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -409,7 +415,50 @@ func TestSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { require.NotNil(t, ctx.Response) assert.Nil(t, ctx.Response.Error()) mockStore.AssertExpectations(t) - mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + +// Reactivating a revoked key when the user is already at the per-user cap must be rejected. +// Without this gate a user at the cap can revoke key A, register fresh key B into the freed +// slot, then re-submit key A with a future expires_at and end up above the cap. +func TestSubmitSessionKeyState_ReactivateAfterRevoke_AtCapRejected(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, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, nil, nil, expiresAt, userSigner, sessionKeySigner) + + prevRevokedExpiresAt := time.Now().Add(-time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(2, prevRevokedExpiresAt, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(3, 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 limit of 3") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreAppSessionKeyState", mock.Anything) } func TestSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { @@ -509,7 +558,7 @@ func TestSubmitSessionKeyState_VersionMismatch(t *testing.T) { // Submit version 3 when latest is 0 (expects 1) reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, time.Time{}, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -547,7 +596,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, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, time.Time{}, nil) mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) payload, err := rpc.NewPayload(reqPayload) @@ -587,7 +636,8 @@ 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, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(4, nil) + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(4, prevActiveExpiresAt, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -852,7 +902,7 @@ func TestSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession). - Return(0, database.ErrSessionKeyNotAllowed) + Return(0, time.Time{}, database.ErrSessionKeyNotAllowed) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) diff --git a/nitronode/api/app_session_v1/testing.go b/nitronode/api/app_session_v1/testing.go index a0770bdda..6621f6f16 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -118,9 +118,13 @@ func (m *MockStore) EnsureNoOngoingEscrowOperation(wallet, asset string) error { return args.Error(0) } -func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) { +func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, time.Time, error) { args := m.Called(userAddress, sessionKey, kind) - return uint64(args.Int(0)), args.Error(1) + var expiresAt time.Time + if v := args.Get(1); v != nil { + expiresAt = v.(time.Time) + } + return uint64(args.Int(0)), expiresAt, args.Error(2) } func (m *MockStore) CountSessionKeysForUser(userAddress string) (uint32, error) { diff --git a/nitronode/api/channel_v1/interface.go b/nitronode/api/channel_v1/interface.go index 37e8dd0f1..56b50d2ac 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -1,6 +1,8 @@ package channel_v1 import ( + "time" + "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/core" @@ -91,8 +93,11 @@ type Store interface { // Session key state operations // LockSessionKeyState locks the (user, session_key, kind) pointer row for the surrounding - // transaction, returning the current version (0 if newly created). - LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) + // transaction, returning the current version (0 if newly created) and the expires_at of + // the matching history row (zero time when version is 0). Callers use the expires_at to + // distinguish a reactivation (prev inactive → submitted active) from a rotation, so the + // per-user cap can be re-checked when a revoked slot is brought back. + LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (latestVersion uint64, latestExpiresAt time.Time, err error) // CountSessionKeysForUser returns the number of distinct session keys for the wallet // across both kinds, used to enforce the per-user cap at submit time. diff --git a/nitronode/api/channel_v1/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index 1255753cc..5bd7db3f3 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -87,11 +87,12 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } // Validate version and store the session key state + now := time.Now() err = h.useStoreInTx(func(tx Store) error { // Lock the (user, session_key, channel) pointer row for the duration of the tx so that // concurrent submits for the same (user, session_key) serialize cleanly and report a // proper "expected version" error rather than racing on the history UNIQUE constraint. - latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindChannel) + latestVersion, latestExpiresAt, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindChannel) if err != nil { if errors.Is(err, database.ErrSessionKeyNotAllowed) { logger.Warn("session key registration collision", @@ -103,8 +104,17 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return rpc.Errorf("failed to lock session key state: %v", err) } - // Enforce the per-user cap when registering a new session key. Existing keys (latestVersion > 0) - // can always be updated regardless of the cap so that legitimate rotation is never blocked. + // Enforce the per-user cap whenever this submit transitions the slot from inactive + // to active — i.e. a brand-new key (latestVersion == 0) or a reactivation where the + // previous latest state was already past its expires_at. A rotation/update against a + // still-active key is not counted again so legitimate rotation is never blocked, and + // a revoke submit (submitted expires_at <= now) decreases the active count so it is + // not subject to the cap either. + // + // Without the reactivation check a user at the cap can revoke key A, register fresh + // key B into the freed slot, then re-submit key A with a future expires_at — the + // `latestVersion > 0` branch would skip the cap check and leave the user above the + // cap. // // TODO(MF-H01-followup): the row lock above only serializes submits for the same // (user, session_key, kind), so two concurrent submits registering *different* new keys @@ -113,7 +123,9 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // soft DOS bound, not a hard quota — a small over-shoot under genuine concurrency is // acceptable. If a hard quota is ever required, take a per-user advisory lock here // (pg_advisory_xact_lock(hashtext(user_address))) before counting. - if latestVersion == 0 && h.maxSessionKeysPerUser > 0 { + prevActive := latestVersion > 0 && latestExpiresAt.After(now) + submittedActive := coreState.ExpiresAt.After(now) + if !prevActive && submittedActive && h.maxSessionKeysPerUser > 0 { count, err := tx.CountSessionKeysForUser(coreState.UserAddress) if err != nil { return rpc.Errorf("failed to count session keys for user: %v", err) @@ -145,7 +157,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) - if !coreState.ExpiresAt.After(time.Now()) { + if !coreState.ExpiresAt.After(now) { logger.Info("channel session key revoked", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, 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 edb6f3d3f..80ebcf0ca 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -79,7 +79,7 @@ func TestChannelSubmitSessionKeyState_Success(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -157,7 +157,7 @@ func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -234,7 +234,7 @@ func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -252,9 +252,10 @@ func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { mockStore.AssertExpectations(t) } -// Covers the typical revocation path: an active key (latestVersion > 0) is deactivated -// by submitting version+1 with a past expires_at. The per-user cap check is short-circuited -// because the key already exists, so CountSessionKeysForUser must not be called. +// Covers the typical revocation path: an active key (latestVersion > 0, prev expires_at in +// the future) is deactivated by submitting version+1 with a past expires_at. The per-user +// cap check is short-circuited because the previous state was already active (revoke +// decreases the active count), so CountSessionKeysForUser must not be called. func TestChannelSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -276,7 +277,8 @@ func TestChannelSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(1, nil) + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(1, prevActiveExpiresAt, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -295,10 +297,12 @@ func TestChannelSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) } -// Covers the re-activation path: after a revoke (latestVersion incremented twice — register -// then revoke), submitting version+1 with a future expires_at re-activates the same session -// key address without re-running the per-user cap check. -func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { +// Covers the re-activation path: after a revoke (latestVersion > 0, prev expires_at in the +// past), submitting version+1 with a future expires_at re-activates the slot — i.e. the +// active count goes from N-1 back to N. Because the previous latest state was inactive, the +// per-user cap MUST be re-checked here so a user at the cap cannot revoke→register-new→ +// reactivate to exceed it. +func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke_BelowCapAllowed(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -319,7 +323,9 @@ func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(2, nil) + prevRevokedExpiresAt := time.Now().Add(-time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(2, prevRevokedExpiresAt, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(4, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -335,7 +341,52 @@ func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke(t *testing.T) { require.NotNil(t, ctx.Response) assert.Nil(t, ctx.Response.Error()) mockStore.AssertExpectations(t) - mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + +// Reactivating a revoked key when the user is already at the per-user cap must be rejected. +// Without this gate a user at the cap can revoke key A, register fresh key B into the freed +// slot, then re-submit key A with a future expires_at and end up above the cap. +func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke_AtCapRejected(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, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + assets := []string{"USDC"} + + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, assets, expiresAt, userSigner, sessionKeySigner) + + prevRevokedExpiresAt := time.Now().Add(-time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(2, prevRevokedExpiresAt, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(3, 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 limit of 3") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreChannelSessionKeyState", mock.Anything) } func TestChannelSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { @@ -433,7 +484,7 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { // Submit version 3 when latest is 0 (expects 1) reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -471,7 +522,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, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) payload, err := rpc.NewPayload(reqPayload) @@ -512,7 +563,8 @@ func TestChannelSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing // Existing key at version 4: submit version 5. Cap must NOT block updates. reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(4, nil) + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(4, prevActiveExpiresAt, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -690,7 +742,7 @@ func TestChannelSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel). - Return(0, database.ErrSessionKeyNotAllowed) + Return(0, time.Time{}, database.ErrSessionKeyNotAllowed) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) diff --git a/nitronode/api/channel_v1/testing.go b/nitronode/api/channel_v1/testing.go index 45ff79d87..fed42034c 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -123,9 +123,13 @@ func (m *MockStore) GetUserChannels(wallet string, status *core.ChannelStatus, a return args.Get(0).([]core.Channel), args.Get(1).(uint32), args.Error(2) } -func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) { +func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, time.Time, error) { args := m.Called(userAddress, sessionKey, kind) - return uint64(args.Int(0)), args.Error(1) + var expiresAt time.Time + if v := args.Get(1); v != nil { + expiresAt = v.(time.Time) + } + return uint64(args.Int(0)), expiresAt, args.Error(2) } func (m *MockStore) CountSessionKeysForUser(userAddress string) (uint32, error) { diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 49d8e1dad..4e4dcb166 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -98,7 +98,12 @@ func upsertCurrentSessionKeyState(tx *gorm.DB, userAddress, sessionKey string, k // 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) { +// +// When locked.Version > 0, the matching history row's expires_at is also returned so callers +// can distinguish a reactivation (prev inactive → submitted active) from a rotation/update +// (prev still active) and re-run the per-user cap on the reactivation path. When +// locked.Version == 0 there is no history yet, so a zero time.Time is returned. +func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, time.Time, error) { userAddress = strings.ToLower(userAddress) sessionKey = strings.ToLower(sessionKey) @@ -110,7 +115,7 @@ func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind Sessi 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) + return 0, time.Time{}, fmt.Errorf("failed to ensure current session key state row exists: %w", err) } query := s.db.Where("session_key = ? AND kind = ?", sessionKey, kind) @@ -126,15 +131,57 @@ func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind Sessi // 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, time.Time{}, fmt.Errorf("session key pointer row missing after seed insert for (session_key=%s, kind=%d)", sessionKey, kind) } - return 0, fmt.Errorf("failed to lock current session key state: %w", err) + return 0, time.Time{}, fmt.Errorf("failed to lock current session key state: %w", err) } if !strings.EqualFold(locked.UserAddress, userAddress) { - return 0, ErrSessionKeyNotAllowed + return 0, time.Time{}, ErrSessionKeyNotAllowed + } + + if locked.Version == 0 { + return 0, time.Time{}, nil + } + + expiresAt, err := s.fetchLatestSessionKeyExpiresAt(userAddress, sessionKey, locked.Version, kind) + if err != nil { + return 0, time.Time{}, err + } + return locked.Version, expiresAt, nil +} + +// fetchLatestSessionKeyExpiresAt returns the expires_at of the history row at +// (user_address, session_key, version) for the given kind. The pointer table guarantees +// at most one such row per (user, key, kind, version) so a missing history row is a hard +// inconsistency and surfaces as an error rather than a zero expiry. +func (s *DBStore) fetchLatestSessionKeyExpiresAt(userAddress, sessionKey string, version uint64, kind SessionKeyKind) (time.Time, error) { + type expiryRow struct { + ExpiresAt time.Time `gorm:"column:expires_at"` + } + + var table string + switch kind { + case SessionKeyKindAppSession: + table = "app_session_key_states_v1" + case SessionKeyKindChannel: + table = "channel_session_key_states_v1" + default: + return time.Time{}, fmt.Errorf("unknown session key kind: %d", kind) + } + + var row expiryRow + err := s.db.Table(table). + Select("expires_at"). + Where("user_address = ? AND session_key = ? AND version = ?", userAddress, sessionKey, version). + Take(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return time.Time{}, fmt.Errorf("session key history row missing for (user=%s, session_key=%s, version=%d, kind=%d)", userAddress, sessionKey, version, kind) + } + return time.Time{}, fmt.Errorf("failed to load latest session key expires_at: %w", err) } - return locked.Version, nil + return row.ExpiresAt, nil } // CountSessionKeysForUser returns the number of distinct active session keys recorded for the diff --git a/nitronode/store/database/current_session_key_state_test.go b/nitronode/store/database/current_session_key_state_test.go index 85830a32b..e667a77ab 100644 --- a/nitronode/store/database/current_session_key_state_test.go +++ b/nitronode/store/database/current_session_key_state_test.go @@ -63,33 +63,66 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { defer cleanup() store := NewDBStore(db) - v, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + v, expiresAt, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) require.NoError(t, err) assert.Equal(t, uint64(0), v) + assert.True(t, expiresAt.IsZero(), "expires_at must be zero when no history row exists") // Second call returns the same seeded row. - v2, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + v2, expiresAt2, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) require.NoError(t, err) assert.Equal(t, uint64(0), v2) + assert.True(t, expiresAt2.IsZero()) }) - t.Run("Returns latest version after a successful submit", func(t *testing.T) { + t.Run("Returns latest version and expires_at after a successful submit", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() store := NewDBStore(db) + futureExpiry := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) state := app.AppSessionKeyStateV1{ UserAddress: testUser1, SessionKey: testSessionKey, Version: 1, - ExpiresAt: time.Now().Add(24 * time.Hour), + ExpiresAt: futureExpiry, UserSig: "0xsig", } require.NoError(t, store.StoreAppSessionKeyState(state)) - v, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + v, expiresAt, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) require.NoError(t, err) assert.Equal(t, uint64(1), v) + assert.WithinDuration(t, futureExpiry, expiresAt, time.Second) + }) + + t.Run("Returns past expires_at for a revoked latest version", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // Active v1 followed by a revoke at v2 with past expires_at. + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig1", + })) + pastExpiry := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 2, + ExpiresAt: pastExpiry, + UserSig: "0xsig2", + })) + + v, expiresAt, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + assert.Equal(t, uint64(2), v) + assert.WithinDuration(t, pastExpiry, expiresAt, time.Second) + assert.True(t, expiresAt.Before(time.Now()), "revoked latest must surface a past expires_at") }) t.Run("Channel and app_session kinds are independent", func(t *testing.T) { @@ -106,14 +139,16 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { UserSig: "0xsig", })) - channelV, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) + channelV, channelExpiresAt, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) require.NoError(t, err) assert.Equal(t, uint64(1), channelV) + assert.False(t, channelExpiresAt.IsZero()) // App-session pointer for the same (user, session_key) is unaffected. - appV, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + appV, appExpiresAt, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) require.NoError(t, err) assert.Equal(t, uint64(0), appV) + assert.True(t, appExpiresAt.IsZero()) }) t.Run("Foreign wallet trying to claim an already-owned (session_key, kind) is rejected", func(t *testing.T) { @@ -122,12 +157,12 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { store := NewDBStore(db) // User1 owns the session key for the app-session kind. - _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + _, _, 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) + _, _, err = store.LockSessionKeyState(testUser2, testSessionKey, SessionKeyKindAppSession) require.Error(t, err) assert.True(t, errors.Is(err, ErrSessionKeyNotAllowed)) }) @@ -137,9 +172,9 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { defer cleanup() store := NewDBStore(db) - _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) + _, _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) require.NoError(t, err) - _, err = store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + _, _, err = store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) require.NoError(t, err) }) @@ -148,7 +183,7 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { defer cleanup() store := NewDBStore(db) - _, err := store.LockSessionKeyState( + _, _, err := store.LockSessionKeyState( "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", SessionKeyKindAppSession, @@ -156,13 +191,14 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { require.NoError(t, err) // Lower-case query returns the same row (no duplicate seeded). - v, err := store.LockSessionKeyState( + v, expiresAt, err := store.LockSessionKeyState( "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", SessionKeyKindAppSession, ) require.NoError(t, err) assert.Equal(t, uint64(0), v) + assert.True(t, expiresAt.IsZero()) count, err := store.CountSessionKeysForUser("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") require.NoError(t, err) @@ -178,7 +214,7 @@ func TestDBStore_CountSessionKeysForUser(t *testing.T) { store := NewDBStore(db) // Seed-only row (Lock with no submit) must not be counted. - _, err := store.LockSessionKeyState(testUser1, testKeyA, SessionKeyKindAppSession) + _, _, err := store.LockSessionKeyState(testUser1, testKeyA, SessionKeyKindAppSession) require.NoError(t, err) count, err := store.CountSessionKeysForUser(testUser1) diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 1cab68d91..6ba03dc39 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -206,9 +206,13 @@ type DatabaseStore interface { // 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) + // stored version for the caller's row and the latestExpiresAt of that version (zero time + // when the latest version is 0, i.e. no history row exists yet). Returns + // ErrSessionKeyNotAllowed if the key is bound to a different wallet for this kind. + // The latestExpiresAt return lets submit handlers distinguish a reactivation + // (prev inactive → submitted active) from a rotation (prev already active) so the + // per-user cap can be re-checked when a revoked slot is brought back. + LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (latestVersion uint64, latestExpiresAt time.Time, err error) // CountSessionKeysForUser returns the number of distinct session keys recorded for the // wallet across both kinds. Used to enforce the per-user cap at submit time. diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index 67e3d1b24..d54c98c0c 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -117,7 +117,10 @@ type ChannelsV1HomeChannelCreatedEvent struct { InitialState StateV1 `json:"initial_state"` } -// ChannelsV1SubmitSessionKeyStateRequest submits the session key state for registration and updates. +// ChannelsV1SubmitSessionKeyStateRequest submits a channel session key state for registration, +// rotation/update, or revocation. A submit whose ExpiresAt is in the past (<= server's now) +// is treated as a revocation: the auth path stops accepting state signed by the key and the +// slot is freed against the per-user cap. type ChannelsV1SubmitSessionKeyStateRequest struct { // State contains the session key metadata and delegation information State ChannelSessionKeyStateV1 `json:"state"` @@ -254,7 +257,10 @@ type AppSessionsV1CreateAppSessionResponse struct { Status string `json:"status"` } -// AppSessionsV1SubmitSessionKeyStateRequest submits the session key state for registration and updates. +// AppSessionsV1SubmitSessionKeyStateRequest submits an app session key state for registration, +// rotation/update, or revocation. A submit whose ExpiresAt is in the past (<= server's now) +// is treated as a revocation: the auth path stops accepting state signed by the key and the +// slot is freed against the per-user cap. type AppSessionsV1SubmitSessionKeyStateRequest struct { // State contains the session key metadata and delegation information State AppSessionKeyStateV1 `json:"state"` diff --git a/sdk/ts/examples/example-app/README.md b/sdk/ts/examples/example-app/README.md index 9d41b6480..5d93f6f72 100644 --- a/sdk/ts/examples/example-app/README.md +++ b/sdk/ts/examples/example-app/README.md @@ -293,7 +293,9 @@ Now `sessionClient` signs off-chain state updates with the session key — no wa ### Revoke -Submit a new version with empty assets to revoke a session key: +Submit a new version with `expires_at` in the past to revoke a session key. The nitronode +treats any submit whose `expires_at <= now` as a revocation: the slot is freed for the +per-user cap, and the auth path stops accepting state signed by the key. ```ts import { EthereumMsgSigner, packChannelKeyStateV1 } from '@layer-3/nitrolite'; @@ -301,12 +303,15 @@ import { EthereumMsgSigner, packChannelKeyStateV1 } from '@layer-3/nitrolite'; const existing = await client.getLastChannelKeyStates(address, sessionKeyAddress); const latest = existing[0]; +// expires_at one second before now is sufficient; the server compares against its own clock. +const pastExpiresAt = (Math.floor(Date.now() / 1000) - 1).toString(); + const revokeState = { user_address: address, session_key: sessionKeyAddress, version: (BigInt(latest.version) + 1n).toString(), - assets: [], - expires_at: latest.expires_at, + assets: latest.assets, + expires_at: pastExpiresAt, user_sig: '', session_key_sig: '', }; @@ -320,7 +325,7 @@ revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revoke const metadataHash = getChannelSessionKeyAuthMetadataHashV1( address, BigInt(revokeState.version), - [], + revokeState.assets, BigInt(revokeState.expires_at), ); revokeState.user_sig = await walletClient.signMessage({ diff --git a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx index 0a1329286..fe5ef109b 100644 --- a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx +++ b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx @@ -501,12 +501,16 @@ export default function WalletDashboard({ } const newVersion = BigInt(ks.version) + 1n; + // Revoke = submit version+1 with expires_at in the past. The nitronode treats any + // submit whose expires_at <= now as a revocation (freeing the per-user cap slot); + // clearing `assets` would leave the latest state still active until natural expiry. + const pastExpiresAt = Math.floor(Date.now() / 1000) - 1; const revokeState = { user_address: address, session_key: ks.session_key, version: newVersion.toString(), - assets: [] as string[], - expires_at: ks.expires_at, + assets: ks.assets, + expires_at: pastExpiresAt.toString(), user_sig: '', session_key_sig: '', }; diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 69fbf2705..992bdeafd 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -1741,10 +1741,16 @@ export class Client { } /** - * Submit a channel session key state for registration or update. + * Submit a channel session key state. Used for three flows: + * - registration: first submit for a (user, session_key) pair (version=1, future expires_at) + * - rotation/update: bump version with a future expires_at to change assets or extend lifetime + * - revocation: bump version with expires_at <= now to retire the key; the slot is freed + * for the per-user cap and the auth path stops accepting state signed by it + * * 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. + * key being registered, rotated, or revoked). Submits without a valid session_key_sig + * are rejected. * * @param state - The channel session key state containing delegation information */ @@ -1829,10 +1835,16 @@ export class Client { } /** - * Submit an app session key state for registration or update. + * Submit an app session key state. Used for three flows: + * - registration: first submit for a (user, session_key) pair (version=1, future expires_at) + * - rotation/update: bump version with a future expires_at to change app/session IDs or extend lifetime + * - revocation: bump version with expires_at <= now to retire the key; the slot is freed + * for the per-user cap and the auth path stops accepting state signed by it + * * 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. + * key being registered, rotated, or revoked). Submits without a valid session_key_sig + * are rejected. * * @param state - The session key state containing delegation information */ From 48b7bccdd62fdf8c3276d1a84026b26b26cbee4a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Thu, 21 May 2026 13:49:25 +0300 Subject: [PATCH 4/4] YNU-918: fix Auto Sign disable revoke path in example app handleDisableAutoSign still cleared assets while keeping the original future expires_at, so under the submit-as-revoke semantics it reported a successful on-chain revoke while leaving the latest state active. Mirror the table/list revoke fix: keep assets, set expires_at to now-1s. --- .../example-app/src/components/WalletDashboard.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx index fe5ef109b..63428f5e5 100644 --- a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx +++ b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx @@ -296,18 +296,21 @@ export default function WalletDashboard({ try { setSkLoading(true); - // Revoke on-chain: submit a new version with zero assets + // Revoke on-chain: submit a new version with expires_at in the past. The nitronode + // treats any submit whose expires_at <= now as a revocation (freeing the per-user cap + // slot); clearing `assets` would leave the latest state still active until natural expiry. try { const existing = await client.getLastChannelKeyStates(address, sessionKey.address); if (existing && existing.length > 0) { const latest = existing[0]; const newVersion = BigInt(latest.version) + 1n; + const pastExpiresAt = Math.floor(Date.now() / 1000) - 1; const revokeState = { user_address: address, session_key: sessionKey.address, version: newVersion.toString(), - assets: [] as string[], - expires_at: latest.expires_at, + assets: latest.assets, + expires_at: pastExpiresAt.toString(), user_sig: '', session_key_sig: '', };