Skip to content

feat(tempo): add direct session voucher signing#455

Open
deodad wants to merge 2 commits into
wevm:mainfrom
deodad:dad/p256-session-vouchers
Open

feat(tempo): add direct session voucher signing#455
deodad wants to merge 2 commits into
wevm:mainfrom
deodad:dad/p256-session-vouchers

Conversation

@deodad
Copy link
Copy Markdown

@deodad deodad commented May 16, 2026

Summary

Adds direct session voucher signing while keeping escrow verification limited to canonical secp256k1 signatures.

Motivation

Session channels store an authorizedSigner on-chain, but the client does not need a separate signer-address option. The voucher signer is the source of truth. PR #455 also made off-chain verification accept P256/WebAuthn TIP-1020 envelopes, but the current escrow settles only raw secp256k1 ECDSA vouchers.

Changes

  • Derive authorizedSigner from voucherSigner ?? account in src/tempo/client/Session.ts and src/tempo/client/ChannelOps.ts.
  • Add voucherSigner to session() and sessionManager() for direct voucher digest signing while keeping transaction/open signing on the session account.
  • Reject access-key session accounts without a direct voucherSigner, since current viem keychain signing produces envelopes that do not settle as raw vouchers.
  • Keep verifyVoucher limited to canonical secp256k1 bytes until TIP-1020 escrow verification ships.
  • Add tests for P256/WebAuthn signing, secp256k1 voucher signer derivation, access-key guard behavior, v1 unwrap, and non-canonical secp256k1 rejection.

Testing

  • pnpm check:types
  • ./node_modules/.bin/vp test src/tempo/session/Voucher.test.ts src/tempo/client/Session.test.ts src/tempo/client/ChannelOps.test.ts - 3 files passed, 57 tests passed
  • pnpm check
  • git diff --check

@deodad deodad marked this pull request as ready for review May 16, 2026 17:38
@deodad deodad force-pushed the dad/p256-session-vouchers branch from 6796108 to 1862b7b Compare May 16, 2026 17:49
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 18, 2026

Open in StackBlitz

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

commit: 1862b7b

Comment thread src/tempo/client/Session.ts Outdated
type Parameters = Account.getResolver.Parameters &
Client.getResolver.Parameters & {
/** Address authorized to sign vouchers. Defaults to the account address. Use when a separate access key (e.g. secp256k1) signs vouchers while the root account funds the channel. */
/** Address authorized to sign vouchers. Defaults to the voucher signer address, access key address, or account address. */
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍

Copy link
Copy Markdown

@tempoxyz-cyclops-bot tempoxyz-cyclops-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

PR #455 adds delegated voucherSigner support for tempo/session, threading a separate signing account through session() / sessionManager() / ChannelOps, replacing recoverTypedDataAddress with SignatureEnvelope.verify in verifyVoucher, and extracting signVoucherDigest / normalizeVoucherSignature helpers. The plumbing is coherent inside session(), but the verifier change widens what the server accepts off-chain in ways the on-chain settlement path does not mirror, and a pre-existing channel-recovery branch becomes materially more exploitable when clients reuse a stable delegated signer across servers.


🚨 [SECURITY] Cross-channel voucher signing via unvalidated tryRecoverChannel in auto-managed sessions

Severity: Critical
File: src/tempo/client/Session.ts:166-193 (autoManageCredential recovery branch) — not in this PR's diff, so reported here instead of inline
Also affects: src/tempo/client/ChannelOps.ts:219-241 (tryRecoverChannel discards payee/payer/token/authorizedSigner)

In auto-managed mode, when there is no cached entry for the current challenge's (payee, currency, escrow) key, the client takes suggestedChannelId = context?.channelId ?? md?.channelId from the untrusted 402 challenge and calls tryRecoverChannel(). tryRecoverChannel only uses deposit/finalized/settled and discards payee, payer, token, and authorizedSigner. autoManageCredential then stores the recovered entry under the malicious server's key and signs a voucher for that arbitrary channelId.

Because vouchers sign only (channelId, cumulativeAmount) under the escrow/chain EIP-712 domain — not the HTTP challenge or server identity — a malicious payee (Bob) who has observed an unrelated Alice→Charlie channel can present its channelId in his challenge. Alice's client signs a higher cumulative voucher for the Alice→Charlie channel, which Bob can forward to Charlie to spend Alice's funds. This bug pre-dates the PR but is materially more exploitable now that the PR encourages a stable delegated voucherSigner reused across servers.

Recommended Fix: Have tryRecoverChannel return the full on-chain channel state, and in autoManageCredential validate the recovered channel before signing:

if (onChain.payee.toLowerCase() !== payee.toLowerCase()) throw new Error('payee mismatch')
if (onChain.token.toLowerCase() !== currency.toLowerCase()) throw new Error('token mismatch')
if (onChain.payer.toLowerCase() !== account.address.toLowerCase()) throw new Error('payer mismatch')

The server-side open path already does analogous (payee, token) checks at src/tempo/server/Session.ts:483-506; mirror them on the client recovery path.


The two in-diff verifyVoucher issues (P256 uncollectability and non-canonical-byte canonicalization mismatch) are detailed in an inline comment on src/tempo/session/Voucher.ts.

Reviewer Callouts
  • createOpenPayload latent gap (src/tempo/client/ChannelOps.ts:126-207): when called directly with only voucherSigner set and no authorizedSigner, the on-chain open(...) uses account.address as the authorized signer while the voucher is signed by voucherSigner.address, producing an immediately unspendable channel. Today only Session.ts calls this and always pre-fills authorizedSigner = voucherSigner.address, but the helper is exported. Consider deriving authorizedSigner = options.voucherSigner?.address ?? options.authorizedSigner ?? account.address inside the helper, or asserting the invariant.
  • sessionContextSchema.voucherSigner: z.custom<viem_Account>() (src/tempo/client/Session.ts:31): the custom check is empty, so context-supplied voucherSigner is not shape-validated. Safe today because all mi.context.parse(context) call-sites feed local code, but the schema is fragile if any future transport round-trips context from network input.
  • normalizeVoucherSignature silent-pass behavior: if SignatureEnvelope.from(signature) throws for an unrecognized format, the function returns the original bytes unchanged. Combined with the server never re-canonicalizing inbound bytes, the SDK could emit non-canonical bytes that are parseable elsewhere. Consider failing closed.
  • signVoucherDigest shortcut: the new if (account.sign) return account.sign({ hash }) path bypasses viem's signTypedData action and any EIP-1271 / EIP-6492 / smart-account wrapper logic on a LocalAccount-shaped object. Fine today but a footgun for integrators with custom wrappers.
  • Access-key voucher signing removed: the prior signVoucher(client, accessKeyAccount, …, authorizedSigner = accessKeyAddress) flow now throws via the new accessKeyAddress guard. Confirm no downstream consumer still wires an access-key account into session().

primaryType: 'Voucher',
message,
signature: voucher.signature,
return SignatureEnvelope.verify(envelope, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] Off-chain verifyVoucher accepts vouchers that on-chain settlement rejects (P256 + non-canonical secp256k1 bytes)

Two distinct gaps are introduced by switching from recoverTypedDataAddress(voucher.signature) to SignatureEnvelope.verify(envelope, ...) while the server still stores and submits the original voucher.signature bytes (src/tempo/server/Session.ts:562-600, src/tempo/session/Chain.ts:121-189):

  1. P256 / TIP-1020 envelopes (High). Removing the envelope.type !== 'secp256k1' guard lets p256 and webAuthn envelopes pass. The configured TempoStreamChannel escrow (current ABI/defaults; reference uses Solady ECDSA.recoverCalldata) only accepts 64/65-byte ECDSA signatures and reverts InvalidSignature for P256. The payee charges the request and then cannot settle the stored voucher.
  2. Non-canonical secp256k1 bytes (High). SignatureEnvelope.from() strips a trailing SignatureEnvelope.magicBytes suffix during deserialize(), and Signature.vToYParity() folds EIP-155-style v >= 35 to parity. So verifyVoucher returns true for (a) a canonical 65-byte ECDSA signature + 32-byte magic suffix (97 bytes → escrow length check reverts), and (b) a signature with v rewritten from 0x1b/0x1c to 0x23/0x24 (escrow forwards directly to ecrecover, which returns the zero address and reverts). Locally produced signatures are canonicalized by normalizeVoucherSignature(), but parseVoucherFromPayload() does not canonicalize inbound credentials at src/tempo/session/Voucher.ts:147-156.

Recommended Fix:

const envelope = SignatureEnvelope.from(voucher.signature)
if (envelope.type === 'keychain') return false
if (envelope.type !== 'secp256k1') return false // until escrow supports TIP-1020

const canonical = SignatureEnvelope.serialize(envelope)
if (canonical.toLowerCase() !== voucher.signature.toLowerCase()) return false

return SignatureEnvelope.verify(envelope, {
  address: expectedSigner,
  payload: getVoucherDigest(escrowContract, chainId, voucher),
})

Prefer returning the canonical bytes from the verifier (or canonicalizing before persistence) so storage cannot preserve un-settleable input. Add regression tests for the magic-suffix and v >= 35 variants, and a full open→sign→verify→settleOnChain integration test for every signature type the verifier accepts. Keep the TS SDK, mpp-rs, and deployed escrow in lockstep.

@deodad deodad changed the title feat(tempo): support P256 session vouchers feat(tempo): add direct session voucher signing May 21, 2026
@deodad deodad force-pushed the dad/p256-session-vouchers branch from 8ab85a6 to b82f50f Compare May 21, 2026 17:59
@deodad deodad force-pushed the dad/p256-session-vouchers branch from b82f50f to 617ac5a Compare May 21, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants