diff --git a/docs/api.yaml b/docs/api.yaml index 8fa6b21e2..0188f4f26 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -648,11 +648,28 @@ 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. 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. + + 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 - 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 +863,28 @@ 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. 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. + + 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 - 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/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 a10b676af..62f5cf9c8 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 { @@ -97,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", @@ -113,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 @@ -123,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) @@ -155,6 +174,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) + if !coreState.ExpiresAt.After(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..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) @@ -292,7 +292,176 @@ 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, time.Time{}, 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) +} + +// 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() + 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) + + 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) + 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 > 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()) + 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) + + 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) + 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) +} + +// 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) { mockStore := new(MockStore) handler := &Handler{ useStoreInTx: func(handler StoreTxHandler) error { @@ -309,7 +478,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 +496,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) { @@ -388,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) @@ -426,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) @@ -466,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) @@ -731,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 c3c9f8c40..5bd7db3f3 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 { @@ -80,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", @@ -96,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 @@ -106,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) @@ -138,6 +157,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) + if !coreState.ExpiresAt.After(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..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) @@ -212,7 +212,184 @@ 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, time.Time{}, 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) +} + +// 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() + 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) + + 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) + 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 > 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()) + 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) + + 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) + 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) +} + +// 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) { mockStore := new(MockStore) handler := &Handler{ useStoreInTx: func(handler StoreTxHandler) error { @@ -228,7 +405,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 +423,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) { @@ -306,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) @@ -344,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) @@ -385,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) @@ -563,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 2fd124b32..4e4dcb166 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" @@ -97,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) @@ -109,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) @@ -125,30 +131,91 @@ 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, nil + return locked.Version, expiresAt, 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. +// 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 row.ExpiresAt, nil +} + +// 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) + } + + 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) + } + + total := channelCount + appCount + if total < 0 || total > math.MaxUint32 { + return 0, fmt.Errorf("session key count %d out of uint32 range", total) } - return uint32(count), nil + return uint32(total), 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..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) @@ -240,6 +276,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/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/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..b88d3e82a 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -290,10 +290,20 @@ 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 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 +// 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..e05fa0116 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -902,10 +902,20 @@ 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 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 +// 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 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..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: '', }; @@ -501,12 +504,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 */