Skip to content

YNU-918: treat past expires_at as session-key revocation#775

Merged
philanton merged 4 commits into
mainfrom
feat/nitronode-session-key-revoke
May 21, 2026
Merged

YNU-918: treat past expires_at as session-key revocation#775
philanton merged 4 commits into
mainfrom
feat/nitronode-session-key-revoke

Conversation

@philanton
Copy link
Copy Markdown
Contributor

@philanton philanton commented May 19, 2026

Summary

  • Submit handlers for channels.v1.submit_session_key_state and app_sessions.v1.submit_session_key_state accept a past expires_at as a revocation, preserving the monotonic version sequence. The auth path already filters expires_at > now, so the key deactivates 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 against int64 → uint64 wrap in the metadata-hash packer, which would otherwise silently desynchronize the user-signed payload from the persisted row.
  • CountSessionKeysForUser JOINs both history tables and only counts rows with expires_at > now, so a revoke frees the per-user cap slot. A single now is bound for both kind branches. The int64 sum is bounds-checked before the uint32 cast.
  • Emit an Info log (session key revoked / channel session key revoked) when a submission deactivates the key, distinct from the existing successfully stored log.
  • Refresh docs/api.yaml, pkg/rpc/types.go, and the Go SDK Submit* doc comments to describe revoke and re-activation semantics, including the explicit constraint that session_key_sig is required on every submit (including revocation).

Follow-ups (out of scope for this PR)

  • Wallet-only revocation path for lost or compromised keys. The current handler requires session_key_sig on every submit, so a user cannot revoke a key whose private material they no longer control. A wallet-only flow is a separate code path (asymmetric auth, replay-across-wallets threat model, version monotonicity when the key is unrecoverable) and warrants its own ticket.
  • SDK ergonomics — Revoke*SessionKeyState helpers for the Go SDK and the TypeScript SDK, hiding the past-timestamp construction, version bump, and dual signing behind a first-class API. Also touches the TS SDK public-API drift snapshots, so worth a dedicated PR.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./nitronode/api/app_session_v1/... ./nitronode/api/channel_v1/... ./nitronode/store/database/...
  • CI green

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Session key reactivation is now supported after revocation
    • Session key revocation via past expires_at timestamp
  • Documentation

    • API documentation clarified with explicit session key lifecycle semantics (registration, update, revocation, reactivation)
    • Updated examples and guides reflecting revised revocation procedure
    • Clarified validation rules and signature requirements for session key submissions
  • Behavior Changes

    • Per-user session key cap enforcement now applies only when activating previously inactive slots
    • Negative unix timestamps are explicitly rejected
    • Both user and session key signatures required for all submission types

…vocation

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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

Warning

Rate limit exceeded

@philanton has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 29 minutes and 12 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6bce3ba-0998-41e6-a0fd-c5f10c628c31

📥 Commits

Reviewing files that changed from the base of the PR and between f6e7f34 and 48b7bcc.

📒 Files selected for processing (1)
  • sdk/ts/examples/example-app/src/components/WalletDashboard.tsx
📝 Walkthrough

Walkthrough

This PR treats past expires_at as revocation (negative values rejected), extends LockSessionKeyState to return latestExpiresAt with latestVersion, counts only active history rows for per-user caps, enforces caps only when transitioning inactive→active, and updates handlers, mocks, SDK docs, examples, and tests.

Changes

Session Key Revocation Semantics

Layer / File(s) Summary
API, RPC types, SDK & examples docs
docs/api.yaml, pkg/rpc/types.go, sdk/go/*, sdk/ts/*, nitronode/api/app_session_v1/README.md, sdk/ts/examples/*
Doc updates describe expires_at <= now as revocation, negative unix timestamps rejected, required user_sig+session_key_sig on all submits, and wallet-only revocation handled separately; example revoke flows updated.
API interfaces & test mocks
nitronode/api/*/interface.go, nitronode/api/*/testing.go
LockSessionKeyState signature updated to return (latestVersion, latestExpiresAt, err); interfaces import time; mocks updated to return expiry timestamps.
Store implementation: lock & counting
nitronode/store/database/current_session_key_state.go, nitronode/store/database/interface.go
DB Lock returns latest history expires_at for Version>0 (zero time for seed); CountSessionKeysForUser reworked to join history tables and count only active keys (expires_at > now), with uint32 bounds check.
Store tests
nitronode/store/database/current_session_key_state_test.go
Tests assert returned expiresAt semantics (seed zero, active, revoked) and verify revoked/expired keys are excluded from active counts; rotation-with-past-expires frees slots.
App-session handler & tests
nitronode/api/app_session_v1/submit_session_key_state.go, tests
Validation rejects negative expires_at, past timestamps treated as revocation, snapshot now, use latestExpiresAt from lock to decide cap enforcement (only on inactive→active), early-return with revocation log when expires_at <= now; tests added for revoke/reactivate/negative rejects and mocks updated.
Channel handler & tests
nitronode/api/channel_v1/submit_session_key_state.go, tests
Same behavior as app-session handler: non-negative validation, now snapshot, lock returns latestExpiresAt, cap enforced only on inactive→active transitions, early-return and revocation logging; tests added/updated.
Test expectation adjustments
multiple *_test.go
Many tests updated to include explicit time.Time{} or active expiresAt in mocked LockSessionKeyState returns and to expect explicit database.ErrSessionKeyNotAllowed where relevant.

Sequence Diagram


🎯 3 (Moderate) | ⏱️ ~25 minutes


Possibly Related PRs

  • layer-3/nitrolite#739: Session-key ownership and co-signature/locking changes related to SubmitSessionKeyState.
  • layer-3/nitrolite#758: Overlapping changes to LockSessionKeyState and CountSessionKeysForUser affecting cap/locking logic.

Suggested labels

ready


Suggested Reviewers

  • dimast-x
  • ihsraham

"A rabbit taps the log with care,
Past times revoke with quiet flair,
Versions march on in tidy rows,
Slots freed where the old expiry goes,
I nibble a carrot, then repair."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: treating a past expires_at value as an explicit session-key revocation mechanism, which is the core objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nitronode-session-key-revoke

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@nksazonov nksazonov left a comment

Choose a reason for hiding this comment

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

Good job — the revocation approach is clean and the DB-level cap accounting is well-tested.

Revocation requires the compromised key's private key. Both handlers require session_key_sig unconditionally, even on the revocation path (app_session_v1/submit_session_key_state.go:95, channel_v1/submit_session_key_state.go:78). This means a user cannot revoke a lost or compromised session key using their wallet signature alone — the compromised key must co-sign the revocation. Worth documenting explicitly; if wallet-only revocation is a safety requirement, a separate code path (accepting user_sig alone when expires_at <= now) would be needed.

No handler-level test for re-activation. The PR description documents "a later submit with version+1 and a future expires_at re-activates the same session key address," but no handler test exercises this round-trip (active → revoke → re-activate). The DB test covers slot-freeing only. Silent regressions in the re-activation path wouldn't be caught.

Comment thread nitronode/store/database/current_session_key_state.go Outdated
Comment thread nitronode/api/app_session_v1/submit_session_key_state_test.go Outdated
Comment thread nitronode/api/channel_v1/submit_session_key_state_test.go Outdated
Comment thread sdk/go/app_session.go
…ests

- 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) <noreply@anthropic.com>
@philanton
Copy link
Copy Markdown
Contributor Author

Thanks for the review @nksazonov, addressed:

  1. Re-activation handler test — fixed in f826dd8. Added TestSubmitSessionKeyState_ReactivateAfterRevoke for both the app-session and channel handlers (latestVersion=2, version=3, future expires_at), and a separate _RevokeExistingActiveKey to cover the typical revoke path the inline comments flagged.

  2. Wallet-only revocation — yeah, intentionally out of scope here. The current PR is just the past-expires_at semantics on the existing submit handler. A wallet-only path is a separate flow (asymmetric auth, threat model around replay across wallets, version monotonicity when the key is lost) and I'd rather plan that properly in its own ticket. I've documented the constraint explicitly in docs/api.yaml and the Go SDK doc comments in the same commit so callers know the current limitation. Will open a follow-up ticket.

Also tracking the I4 SDK Revoke* helpers (Go + TS) as a separate follow-up — those touch the TS drift snapshots and ts-compat surface, so worth a dedicated PR.

@ihsraham
Copy link
Copy Markdown
Collaborator

I would keep this open for one closure issue.

CountSessionKeysForUser now excludes inactive keys, but the cap check only runs for brand-new pointer rows (latestVersion == 0) in nitronode/api/app_session_v1/submit_session_key_state.go:133 and the mirrored channel path in nitronode/api/channel_v1/submit_session_key_state.go:116. That lets a user revoke key A, register key B into the freed slot, then reactivate key A with a future expires_at without counting active keys again, ending up above maxSessionKeysPerUser.

One way to close this is to run the cap check whenever the submitted state is active and the previous latest state was inactive or absent, not only when latestVersion == 0.

Minor/non-blocking cleanup: the TS example app still revokes by clearing assets while keeping the old future expires_at (sdk/ts/examples/example-app/README.md:296, sdk/ts/examples/example-app/src/components/WalletDashboard.tsx:503), so under the new semantics it stores an active latest state. Some public comments/docs are also stale (sdk/ts/src/client.ts, pkg/rpc/api.go, and nitronode/api/app_session_v1/README.md) and still describe submit as registration/update only or say expires_at must be future. These are docs/example cleanup items, but worth fixing so integrators do not follow the old behavior.

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.
@philanton
Copy link
Copy Markdown
Contributor Author

Yep, you're right about the bypass — fixed in f6e7f34. Extended LockSessionKeyState to also return the latest history expires_at so the handler can tell rotation from reactivation, and the cap check now fires whenever the submit transitions the slot from inactive to active:

prevActive := latestVersion > 0 && latestExpiresAt.After(now)
submittedActive := coreState.ExpiresAt.After(now)
if !prevActive && submittedActive && h.maxSessionKeysPerUser > 0 {
    // count, enforce
}

So new-key registration, and reactivation from a revoked state, both go through the cap; rotation against a still-active key and revoke submits skip it as before. New tests on both the app-session and channel handler — _ReactivateAfterRevoke_AtCapRejected and _BelowCapAllowed — pin the four branches.

On the cleanup: also fixed in the same commit.

  1. example-app/README.md and WalletDashboard.tsx revoke flow now submits a past expires_at (kept assets/scoping unchanged), since under the new semantics clearing assets alone leaves the latest state active.
  2. Doc comments on submitChannelSessionKeyState / submitSessionKeyState (sdk/ts/src/client.ts), the RPC types in pkg/rpc/api.go, the validation block in nitronode/api/app_session_v1/README.md, and the submit_session_key_state entry in docs/api.yaml all spell out the three flows (register / rotate / revoke) and that reactivation counts against the cap.

Copy link
Copy Markdown
Collaborator

@ihsraham ihsraham left a comment

Choose a reason for hiding this comment

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

Approving. The closure blocker is fixed: reactivation now re-checks the active-key cap, and the new tests cover both below-cap and at-cap reactivation for app-session and channel keys.

Non-blocking cleanup: sdk/ts/examples/example-app/src/components/WalletDashboard.tsx still has one Auto Sign disable path that revokes by clearing assets while keeping the old future expires_at. The table/list revoke path and docs were updated correctly, so I would not block closure on this, but it is worth fixing so the example app does not report a successful on-chain revoke while leaving the latest key state active.

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.
@philanton
Copy link
Copy Markdown
Contributor Author

Yep, good catch — missed the handleDisableAutoSign path. Fixed in 48b7bcc. Same shape as the table/list one: keep assets, set expires_at to now - 1s so the submit lands as a revocation under the new semantics and the cap slot actually frees.

@philanton philanton merged commit e07ad9c into main May 21, 2026
16 checks passed
@philanton philanton deleted the feat/nitronode-session-key-revoke branch May 21, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants