feat(tempo): add direct session voucher signing#455
Conversation
6796108 to
1862b7b
Compare
commit: |
| 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. */ |
tempoxyz-cyclops-bot
left a comment
There was a problem hiding this comment.
👁️ 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
- ⚡
createOpenPayloadlatent gap (src/tempo/client/ChannelOps.ts:126-207): when called directly with onlyvoucherSignerset and noauthorizedSigner, the on-chainopen(...)usesaccount.addressas the authorized signer while the voucher is signed byvoucherSigner.address, producing an immediately unspendable channel. Today onlySession.tscalls this and always pre-fillsauthorizedSigner = voucherSigner.address, but the helper is exported. Consider derivingauthorizedSigner = options.voucherSigner?.address ?? options.authorizedSigner ?? account.addressinside 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-suppliedvoucherSigneris not shape-validated. Safe today because allmi.context.parse(context)call-sites feed local code, but the schema is fragile if any future transport round-tripscontextfrom network input. - ⚡
normalizeVoucherSignaturesilent-pass behavior: ifSignatureEnvelope.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. - ⚡
signVoucherDigestshortcut: the newif (account.sign) return account.sign({ hash })path bypasses viem'ssignTypedDataaction and any EIP-1271 / EIP-6492 / smart-account wrapper logic on aLocalAccount-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 newaccessKeyAddressguard. Confirm no downstream consumer still wires an access-key account intosession().
| primaryType: 'Voucher', | ||
| message, | ||
| signature: voucher.signature, | ||
| return SignatureEnvelope.verify(envelope, { |
There was a problem hiding this comment.
🚨 [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):
- P256 / TIP-1020 envelopes (High). Removing the
envelope.type !== 'secp256k1'guard letsp256andwebAuthnenvelopes pass. The configuredTempoStreamChannelescrow (current ABI/defaults; reference uses SoladyECDSA.recoverCalldata) only accepts 64/65-byte ECDSA signatures and revertsInvalidSignaturefor P256. The payee charges the request and then cannot settle the stored voucher. - Non-canonical secp256k1 bytes (High).
SignatureEnvelope.from()strips a trailingSignatureEnvelope.magicBytessuffix duringdeserialize(), andSignature.vToYParity()folds EIP-155-stylev >= 35to parity. SoverifyVoucherreturnstruefor (a) a canonical 65-byte ECDSA signature + 32-byte magic suffix (97 bytes → escrow length check reverts), and (b) a signature withvrewritten from0x1b/0x1cto0x23/0x24(escrow forwards directly toecrecover, which returns the zero address and reverts). Locally produced signatures are canonicalized bynormalizeVoucherSignature(), butparseVoucherFromPayload()does not canonicalize inbound credentials atsrc/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.
8ab85a6 to
b82f50f
Compare
b82f50f to
617ac5a
Compare
Summary
Adds direct session voucher signing while keeping escrow verification limited to canonical secp256k1 signatures.
Motivation
Session channels store an
authorizedSigneron-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
authorizedSignerfromvoucherSigner ?? accountinsrc/tempo/client/Session.tsandsrc/tempo/client/ChannelOps.ts.voucherSignertosession()andsessionManager()for direct voucher digest signing while keeping transaction/open signing on the session account.voucherSigner, since current viem keychain signing produces envelopes that do not settle as raw vouchers.verifyVoucherlimited to canonical secp256k1 bytes until TIP-1020 escrow verification ships.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 passedpnpm checkgit diff --check