Skip to content

feat: improve session ergonomics via server-provided session hints#260

Open
brendanjryan wants to merge 6 commits intomainfrom
br/session-hints-reconciliation
Open

feat: improve session ergonomics via server-provided session hints#260
brendanjryan wants to merge 6 commits intomainfrom
br/session-hints-reconciliation

Conversation

@brendanjryan
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan commented Mar 29, 2026

Summary

Implements stateless resume for tempo/session.

A client can reuse an existing Tempo payment channel without persisting local session runtime state, as long as either:

  • the client already knows a channelId, or
  • the server can resolve the authenticated payer identity and discover a reusable channel for that payer.

The implementation adds server-side reusable-channel discovery, client-side session hydration from challenge hints, zero-dollar proof bootstrap in SessionManager, and receipt reconciliation that keeps server accounting authoritative without letting server hints inflate the next signed voucher.

What This PR Implements

  • server-side reusable channel discovery keyed by payer + payee + token + escrow contract + chain
  • tempo.session({ resolveSource }) so an application can supply the authenticated payer identity from its own auth/session layer
  • session challenge hints in request.methodDetails: channelId, acceptedCumulative, requiredCumulative, spent, and deposit
  • client-side hydration of session state from server hints, with on-chain recovery as the fallback when only channelId is known
  • bounded zero-dollar proof bootstrap in SessionManager so a stateless client can authenticate first, receive a session challenge, and then resume or open a channel
  • Payment-Receipt and SSE reconciliation that updates local accounting state while preserving the client’s own signing boundary
  • deterministic precedence for explicit channelId, which remains the direct reuse path when the client already has it

End-to-End Flow

sequenceDiagram
    participant Client
    participant Server
    participant Auth as Zero-dollar proof auth
    participant Session as Session challenge
    participant Store as Channel store
    participant Chain as On-chain channel

    Client->>Server: GET /resource
    alt auth required and no local session runtime
        Server-->>Client: 402 + tempo.charge(amount=0)
        Client->>Auth: sign proof credential
        Client->>Server: retry with proof credential
    end

    Server->>Store: resolve payer and discover reusable channel
    Server-->>Client: 402 + tempo.session challenge

    alt explicit or discovered reusable channel available
        Server-->>Client: channelId + accepted/spent/deposit/required hints
        Client->>Session: hydrate local runtime state from hints
    else channelId known but no server snapshot
        Client->>Chain: recover channel state on-chain
        Chain-->>Client: settled amount + deposit
    else no reusable channel
        Client->>Chain: open new channel
    end

    Client->>Server: retry with open/voucher credential
    Server-->>Client: 200 + Payment-Receipt(channelId, acceptedCumulative, spent)
    Client->>Session: reconcile receipt into local accounting state
Loading

Server Behavior

When the server receives a tempo/session request, it can now attach reusable-channel hints to the session challenge.

If request.channelId is present, that channel is treated as the requested reuse target. The server verifies that the stored channel matches the request dimensions and, when valid, returns challenge hints for that channel.

If request.channelId is absent and resolveSource() returns a payer DID such as did:pkh:eip155:<chainId>:<address>, the server:

  • parses the authenticated payer address from the DID
  • looks up channels owned by that payer
  • filters candidates to the same recipient, token, escrow contract, and chain
  • excludes channels that are finalized, closing, zero-deposit, or unable to satisfy the next request amount
  • selects a deterministic best candidate, preferring the newest viable channel and then stronger voucher progress as a tiebreaker

The backing ChannelStore gains payer-indexed lookup to support this discovery efficiently.

Client Behavior

The client now distinguishes between:

  • server-reported accounting state: acceptedCumulative, spent, deposit
  • client-authorized signing state: cumulativeAmount

This distinction is the core of the reconciliation model.

Server hints and receipts can raise the client’s view of accepted/spent/deposit, but they do not directly increase the next signed voucher amount. The next voucher is still derived from the client’s locally authorized cumulative amount plus the current request or stream increment.

This allows the client to resume from server knowledge, process receipts, and handle SSE top-ups without trusting the server to choose a larger authorization boundary than the client intended to sign.

SessionManager now owns a bounded retry loop for this flow:

  1. perform the original request
  2. if challenged for zero-dollar auth, create a proof credential and retry
  3. read the returned session challenge
  4. hydrate or recover the target channel
  5. create the session credential and retry
  6. reconcile the resulting receipt into runtime state

API and Plumbing Changes

  • tempo.session() server request hooks now receive the incoming HTTP Request so resolveSource() can inspect application auth context
  • Fetch.from() invokes method response hooks for successful retry responses so session receipt reconciliation also works through the generic client path
  • tempo/session request parsing accepts additive session hint fields and carries them through methodDetails

Compatibility

  • all new session hint fields are additive and optional
  • explicit channelId reuse continues to work as the direct fast path
  • resolveSource is optional; when absent, the flow still supports explicit channelId reuse and opening new channels
  • clients that do not use the new hint fields continue to operate with the existing open/retry behavior

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 29, 2026

Open in StackBlitz

npm i https://pkg.pr.new/mppx@260

commit: 2065260

@brendanjryan brendanjryan marked this pull request as ready for review March 31, 2026 19:59
@brendanjryan brendanjryan changed the title feat: add tempo session hint reconciliation feat: improve session ergonomics via server-provided session hints Mar 31, 2026
Copy link
Copy Markdown

@decofe decofe left a comment

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown

@decofe decofe left a comment

Choose a reason for hiding this comment

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

🛡️ Cyclops Security Review — Consolidated Findings

Commit: 727181b
Mode: normal · 3 workers (gemini-3.1-pro-preview, amp/deep, gpt-5.4)

Verified Findings

After multi-engine audit and independent verification, 2 findings were confirmed:


🚨 Finding 1: Malicious Server Can Drain Client Deposit via Forged Session Hints — Critical

Affected code: src/tempo/client/ChannelOps.ts:L124-L127, src/tempo/client/Session.ts:L291-L293, src/client/internal/Fetch.ts

Description: The monotonic session hint reconciliation path blindly trusts server-provided acceptedCumulative, spent, and requiredCumulative hints, using them to overwrite entry.cumulativeAmount (the sum of authorized payments). A malicious server can forge these hints to trick the client into signing a voucher for the entire channel deposit.

Attack path:

  1. Client opens a channel with deposit of 1000 tokens, requests a $5 resource.
  2. Malicious server responds with 402 challenge containing forged hints: requiredCumulative = 1000 (or returns Payment-Receipt with spent = 1000).
  3. reconcileChannelEntry and autoManageCredential blindly adopt this value, updating local entry.cumulativeAmount to 1000.
  4. autoManageCredential signs a voucher authorizing 1000 tokens and sends it to the server.
  5. Server settles the voucher on-chain, stealing the full deposit.

Root cause: reconcileChannelEntry() raises local channel state from server-provided values without bounding, and autoManageCredential() uses max(entry.cumulativeAmount + amount, requiredCumulative) before signing.

Recommended fix: The client must never increase entry.cumulativeAmount based on untrusted server hints. Remove the entry.cumulativeAmount overrides in reconcileChannelEntry, and remove the requiredCumulative bump in autoManageCredential. Server hints may warn of desynchronization but must not inflate cryptographic authorization.


🚨 Finding 2: Stale channel aliases let old receipts poison a fresh channel — High

Affected code: src/tempo/client/Session.ts:L121-L125 (rememberChannel), src/tempo/client/Session.ts:L199-L206 (reconcileReceipt), src/tempo/client/ChannelOps.ts:L115-L161

Description: rememberChannel() adds channelId → key entries for every recovered/hinted channel but never removes the old mapping when the same (payee, currency, escrow) key is rebound to a new channel. A legitimate old receipt for channel A can ratchet the signable state of channel B via the stale alias.

Attack path:

  1. Client binds key payee:currency:escrow to channel A.
  2. Server rotates session to channel B for the same key — channels[key] is overwritten to B, but channelIdToKey[A] = key is not removed.
  3. A replayed Payment-Receipt for channel A hits reconcileReceipt().
  4. Lookup resolves: channelIdToKey[A] → key → channels[key] → channel B (without verifying entry.channelId === receipt.channelId).
  5. reconcileChannelReceipt() raises B's acceptedCumulative, spent, and cumulativeAmount from A's receipt.
  6. Next auto-managed request signs an inflated voucher on B.

Recommended fix: Remove stale alias when rebinding a key to a new channel. Add a guard in reconcileReceipt to reject any receipt whose channelId does not match the loaded ChannelEntry.


Deduplicated / Rejected Findings

# Finding Severity Verdict Reason
2 Failed Fix & Variants for "Forged Session Hints" Critical ⏩ Dup Same root cause as Finding 1
4 Cross-Channel Signature Replay (Confused Deputy) Critical ❌ Rejected Channel-bound signatures prevent cross-service replay
5 Stale receipt aliasing lets channel A over-authorize channel B High ⏩ Dup Same root cause as Finding 2
6 Session hint breaks bodyless control flows on dynamic-priced routes Medium ❌ Rejected Dynamic pricing handled before hint reconciliation

🔒 Automated security review by Cyclops · 3 engines · 6 raw findings · 2 verified

@brendanjryan brendanjryan force-pushed the br/session-hints-reconciliation branch from cd52300 to 8d991bc Compare March 31, 2026 21:33
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

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

👁️ Cyclops Review

Note: Audit was performed against 727181b; current head is 8d991bc. The current head already fixes two issues found during audit (stale channelIdToKey aliases are now deleted in rememberChannel(), and reconcileReceipt() now checks entry.channelId === receipt.channelId). Three findings remain on the current head.

Findings Summary

# Tier Finding File
1 🚨 SECURITY Server-supplied requiredCumulative overrides client's locally-tracked authorization src/tempo/client/Session.ts:291-293 (pre-existing line, newly reachable via PR)
2 🚨 SECURITY Global fetch polyfill exposes session receipt reconciliation to third-party responses src/client/internal/Fetch.ts:54, src/tempo/client/Session.ts:496-502
3 ⚠️ ISSUE Unrecoverable server-suggested channelId silently orphans active channel src/tempo/client/Session.ts:270
Reviewer Callouts
  • requiredCumulative trust boundary: The max(nextCumulative, requiredCumulative) pattern at Session.ts:291-293 is the primary remaining trust inversion. The test suite explicitly encodes this as intended ("prefers requiredCumulative"), so this is a design-level decision needing human review.
  • Global fetch blast radius: The combination of Mppx.create() default polyfill + Fetch.from() response hooks + tempo() bundling session is a cross-cutting concern that only becomes dangerous when all three are active together.
  • reconcileChannelEntry advisory semantics: acceptedCumulative, spent, and deposit are updated from server hints, but cumulativeAmount is kept local. This separation is correct — except that requiredCumulative bypasses it by directly entering the signing path.
  • Channel rebinding policy: Session.ts:244-254 now lets the server drive channel replacement for existing slots. A human reviewer should verify whether any server hint should ever override a still-usable local channel.

@brendanjryan brendanjryan force-pushed the br/session-hints-reconciliation branch from 7f61f06 to 2065260 Compare March 31, 2026 23:36
@tempoxyz-bot
Copy link
Copy Markdown

tempoxyz-bot commented Mar 31, 2026

👁️ Cyclops Security Review

2065260

🧭 Auditing · mode=normal · workers 1/3 done (2 left) · verify pending 1

Worker Engine Progress Status
pr-260-w1 gemini-3.1-pro-preview ❌ thread-1 🚨 thread-2 🚨 thread-3 Done
pr-260-w2 amp/deep 🚨 thread-1 🚨 thread-2 🔍 thread-3 Running
pr-260-w3 gpt-5.4 🚨 thread-1 🔍 thread-2 · Running

Findings

# Finding Severity Verification Threads
1 Server-provided session hints can inflate the close voucher High ✅ Verified audit · verify
2 Arbitrary Close Voucher Forgery via Malicious Hint High ⏩ Dup audit · verify
3 Stateless Resume Blindly Accepts Arbitrary channelId, Enabling Cross-Channel Voucher Theft Critical ✅ Verified audit · verify
4 Stateless resume signs a voucher the unchanged server will always reject Medium ⏳ Pending audit
5 Server-authored session hints poison cooperative close vouchers High ⏩ Dup audit · verify
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 29 events

🔍 pr-260-w1 iter 1/3 [audit-ripple.md]
🔍 pr-260-w2 iter 1/3 [audit-focused.md]
🔍 pr-260-w3 iter 1/3 [audit-deep-focus.md]
pr-260-w1 iter 1 — failed | Thread
🔍 pr-260-w1 iter 2/3 [audit-historical.md]
🚨 pr-260-w2 iter 1 — finding | Thread
🚨 Finding: Server-provided session hints can inflate the close voucher (High) | Thread
🔍 pr-260-w2 iter 2/3 [audit-ripple.md]
🔬 Verifying: Server-provided session hints can inflate the close voucher | Thread
📋 Verify: Server-provided session hints can inflate the close voucher → ✅ Verified | Thread
🚨 pr-260-w1 iter 2 — finding | Thread
🚨 Finding: Arbitrary Close Voucher Forgery via Malicious Hint (High) | Thread
🔍 pr-260-w1 iter 3/3 [audit-focused.md]
🔬 Verifying: Arbitrary Close Voucher Forgery via Malicious Hint | Thread
📋 Verify: Arbitrary Close Voucher Forgery via Malicious Hint → ⏩ Dup | Thread
🚨 pr-260-w1 iter 3 — finding | Thread
🚨 Finding: Stateless Resume Blindly Accepts Arbitrary channelId, Enabling Cross-Channel Voucher Theft (Critical) | Thread
🏁 pr-260-w1 done
🔬 Verifying: Stateless Resume Blindly Accepts Arbitrary channelId, Enabling Cross-Channel Voucher Theft | Thread
🚨 pr-260-w2 iter 2 — finding | Thread
🚨 Finding: Stateless resume signs a voucher the unchanged server will always reject (Medium) | Thread
🔍 pr-260-w2 iter 3/3 [audit-historical.md]
🔬 Verifying: Stateless resume signs a voucher the unchanged server will always reject | Thread
📋 Verify: Stateless Resume Blindly Accepts Arbitrary channelId, Enabling Cross-Channel Voucher Theft → ✅ Verified | Thread
🚨 pr-260-w3 iter 1 — finding | Thread
🚨 Finding: Server-authored session hints poison cooperative close vouchers (High) | Thread
🔍 pr-260-w3 iter 2/3 [audit-focused.md]
🔬 Verifying: Server-authored session hints poison cooperative close vouchers | Thread
📋 Verify: Server-authored session hints poison cooperative close vouchers → ⏩ Dup | Thread

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