From 1698465c5738893699292bbe2af25dff99e271a5 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 22 Jul 2026 12:05:41 -0400 Subject: [PATCH] fix: reject incompatible MPP voucher keys Fixes SU-332 Amp-Thread-ID: https://ampcode.com/threads/T-019f8a78-b9d3-749e-9f44-2966e6789ab3 Co-authored-by: Amp --- .../fix-incompatible-mpp-voucher-key.md | 5 + src/commands/request.ts | 44 ++++++++- src/provider.ts | 1 + test/identity.test.ts | 1 + test/request.test.ts | 93 ++++++++++++++++++- 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 .changelog/fix-incompatible-mpp-voucher-key.md diff --git a/.changelog/fix-incompatible-mpp-voucher-key.md b/.changelog/fix-incompatible-mpp-voucher-key.md new file mode 100644 index 0000000..c1cf2a6 --- /dev/null +++ b/.changelog/fix-incompatible-mpp-voucher-key.md @@ -0,0 +1,5 @@ +--- +tempo-request: patch +--- + +Reject incompatible P-256/WebAuthn access keys before opening MPP payment sessions and explain how to create a secp256k1 key and clear stale sessions. diff --git a/src/commands/request.ts b/src/commands/request.ts index 6f67df6..3f0ad08 100644 --- a/src/commands/request.ts +++ b/src/commands/request.ts @@ -552,7 +552,7 @@ async function paySessionAndRetryRequest( skipChannelId?: string | undefined, ) { try { - const identity = await resolvePaymentIdentity(options); + const identity = await resolveSessionPaymentIdentity(options); const details = sessionDetails(challenge, request.url, options); const reusable = await reusableSessionRecord(details, identity, skipChannelId); const challengeResponse = tempoPaymentChallengeResponse(paymentRequiredResponse); @@ -645,6 +645,25 @@ async function paySessionAndRetryRequest( } } +export async function resolveSessionPaymentIdentity(options: RequestOptions) { + if (options.privateKey ?? process.env.TEMPO_PRIVATE_KEY) return resolvePaymentIdentity(options); + + const walletState = await loadWalletState(); + const secp256k1Identity = await storedAccessKeyIdentity(walletState, options, "secp256k1"); + if (secp256k1Identity) return secp256k1Identity; + + if (walletState.accounts[walletState.activeAccount ?? 0]) throw sessionAccessKeyError(options); + + return resolvePaymentIdentity(options); +} + +function sessionAccessKeyError(options: RequestOptions) { + const networkOption = options.network === "testnet" ? " --network testnet" : ""; + return paymentError( + `MPP session vouchers require an active, unexpired secp256k1 access key; P-256/WebAuthn access keys are incompatible. Your passkey wallet is supported; only its access key needs to change. Run 'tempo wallet refresh${networkOption}' to create and select a secp256k1 access key. Close stale sessions with 'tempo wallet sessions close --all${networkOption}'. If a sponsored transaction was already prepared, discard it before retrying.`, + ); +} + export async function resolvePaymentIdentity(options: RequestOptions) { const privateKey = options.privateKey ?? process.env.TEMPO_PRIVATE_KEY; if (privateKey) { @@ -705,14 +724,21 @@ async function ensureProviderAccounts(provider: Parameters[0]) { await connect(provider); } -export async function storedAccessKeyIdentity(walletState: WalletState, options: RequestOptions) { +export async function storedAccessKeyIdentity( + walletState: WalletState, + options: RequestOptions, + requiredKeyType?: "secp256k1" | "p256", +) { const activeAccount = walletState.accounts[walletState.activeAccount ?? 0]; if (!activeAccount) return undefined; const expectedChain = chainId(options.network); for (const key of walletState.accessKeys) { if (key.chainId !== expectedChain) continue; + if (key.access.toLowerCase() !== activeAccount.address.toLowerCase()) continue; + if (key.expiry !== undefined && key.expiry <= nowSeconds()) continue; if (key.keyType && key.keyType !== "secp256k1" && key.keyType !== "p256") continue; + if (requiredKeyType && (key.keyType ?? "secp256k1") !== requiredKeyType) continue; const keyAuthorizationManager = KeyAuthorizationManager.memory(); if (key.keyAuthorization) { @@ -748,7 +774,19 @@ export async function storedAccessKeyIdentity(walletState: WalletState, options: keyAuthorizationManager, }, ) - : undefined; + : key.keyType === "secp256k1" && key.handle && key.publicKey + ? await Keystore.secp256k1().toAccount( + { + handle: key.handle as Keystore.Handle, + keyType: key.keyType, + publicKey: key.publicKey as `0x${string}`, + }, + { + access: activeAccount.address as `0x${string}`, + keyAuthorizationManager, + }, + ) + : undefined; if (!account) continue; if (key.address.toLowerCase() !== account.accessKeyAddress.toLowerCase()) continue; diff --git a/src/provider.ts b/src/provider.ts index 9b0f58b..77c468f 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -31,6 +31,7 @@ export async function connect(provider: CoreProvider.Provider) { capabilities: { authorizeAccessKey: { expiry: Math.floor(Date.now() / 1000) + accessKeyAuthorizationSeconds, + keyType: "secp256k1", }, }, }, diff --git a/test/identity.test.ts b/test/identity.test.ts index dd5284b..bf9ba0c 100644 --- a/test/identity.test.ts +++ b/test/identity.test.ts @@ -627,6 +627,7 @@ limit = "100000000" capabilities: { authorizeAccessKey: { expiry: Math.floor(Date.now() / 1000) + accessKeyAuthorizationSeconds, + keyType: "secp256k1", }, }, }, diff --git a/test/request.test.ts b/test/request.test.ts index e532992..964cb43 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -14,6 +14,7 @@ import { isSessionInvalidationResponse, parseRequestArgs, resolvePaymentIdentity, + resolveSessionPaymentIdentity, runRequest, storedAccessKeyIdentity, tempoPaymentChallengeResponse, @@ -435,6 +436,7 @@ describe("request command", () => { accessKeys: [ { ...walletState().accessKeys[0]!, + expiry: 2_000_000_000, keyAuthorization, }, ], @@ -456,6 +458,95 @@ describe("request command", () => { expect(stored).toStrictEqual(keyAuthorization); }); + it("uses a stored secp256k1 access key for MPP session vouchers", async () => { + await useTempHome(); + const p256Keystore = Keystore.webCryptoP256({ extractable: true }); + const p256Key = await p256Keystore.createKey(); + const p256Account = await p256Keystore.toAccount( + { ...p256Key, keyType: "p256" }, + { access: testWallet, keyAuthorizationManager: KeyAuthorizationManager.memory() }, + ); + const secp256k1Keystore = Keystore.secp256k1(); + const secp256k1Key = await secp256k1Keystore.createKey(); + const secp256k1Account = await secp256k1Keystore.toAccount( + { ...secp256k1Key, keyType: "secp256k1" }, + { access: testWallet, keyAuthorizationManager: KeyAuthorizationManager.memory() }, + ); + await writeWalletState( + walletState({ + accessKeys: [ + { + address: p256Account.accessKeyAddress, + access: testWallet, + chainId: 4217, + handle: p256Key.handle, + keyType: "p256", + limits: [], + publicKey: p256Key.publicKey, + }, + { + address: secp256k1Account.accessKeyAddress, + access: testWallet, + chainId: 4217, + handle: secp256k1Key.handle, + keyType: "secp256k1", + limits: [], + publicKey: secp256k1Key.publicKey, + }, + ], + }), + ); + + const identity = await resolveSessionPaymentIdentity( + requestOptions("https://paid.example.com"), + ); + + expect(identity.signerAddress.toLowerCase()).toBe( + secp256k1Account.accessKeyAddress.toLowerCase(), + ); + expect(identity.methodOptions).toMatchObject({ + account: { + accessKeyAddress: secp256k1Account.accessKeyAddress, + keyType: "secp256k1", + }, + mode: "pull", + }); + }); + + it("rejects a P-256 access key before creating an MPP session voucher", async () => { + await useTempHome(); + const keystore = Keystore.webCryptoP256({ extractable: true }); + const { handle, publicKey } = await keystore.createKey(); + const account = await keystore.toAccount( + { handle, keyType: "p256", publicKey }, + { access: testWallet, keyAuthorizationManager: KeyAuthorizationManager.memory() }, + ); + await writeWalletState( + walletState({ + accessKeys: [ + { + address: account.accessKeyAddress, + access: testWallet, + chainId: 4217, + handle, + keyType: "p256", + limits: [], + publicKey, + }, + ], + }), + ); + + await expect( + resolveSessionPaymentIdentity(requestOptions("https://paid.example.com")), + ).rejects.toMatchObject({ + code: "E_PAYMENT", + message: expect.stringMatching( + /MPP session vouchers require an active, unexpired secp256k1 access key.*P-256\/WebAuthn.*passkey wallet is supported.*tempo wallet refresh.*tempo wallet sessions close --all.*sponsored transaction.*discard/is, + ), + }); + }); + it("uses stored P-256 access keys for payment identity resolution", async () => { await useTempHome(); const keystore = Keystore.webCryptoP256({ extractable: true }); @@ -471,7 +562,7 @@ describe("request command", () => { address: account.accessKeyAddress, access: testWallet, chainId: 4217, - expiry: 1783809942, + expiry: 2_000_000_000, handle, keyType: "p256", limits: [],