Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions docs/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions nitronode/api/app_session_v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion nitronode/api/app_session_v1/interface.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 33 additions & 6 deletions nitronode/api/app_session_v1/submit_session_key_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading