diff --git a/.changeset/access-key-verify-hash.md b/.changeset/access-key-verify-hash.md new file mode 100644 index 0000000000..a16109d649 --- /dev/null +++ b/.changeset/access-key-verify-hash.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +**viem/tempo:** Added access key signature verification support to `verifyHash` via `mode: 'allowAccessKey'`. diff --git a/src/actions/public/verifyHash.ts b/src/actions/public/verifyHash.ts index cd18e25daf..7fdb32aa23 100644 --- a/src/actions/public/verifyHash.ts +++ b/src/actions/public/verifyHash.ts @@ -80,7 +80,7 @@ export type VerifyHashParameters = Pick< /** @deprecated use `erc6492VerifierAddress` instead. */ universalSignatureVerifierAddress?: Address | undefined /** Chooses which verification path to try first before falling back. */ - mode?: 'auto' | 'eoa' | undefined + mode?: 'auto' | 'eoa' | (string & {}) | undefined } & OneOf<{ factory: Address; factoryData: Hex } | {}> export type VerifyHashReturnType = boolean diff --git a/src/tempo/Account.test.ts b/src/tempo/Account.test.ts index 2a00c9d448..d13747548c 100644 --- a/src/tempo/Account.test.ts +++ b/src/tempo/Account.test.ts @@ -106,7 +106,7 @@ describe('fromP256', () => { hash: '0xdeadbeef', }) expect(signature).toMatchInlineSnapshot( - `"0x01daab749a3dea3f76c52ff0cfc86f0d433ecaf4d20f2ea327042bf5c15bccf847098dc3591fc68bf94d8db6d16cf326808dbf0f44d8e8373e8a7fcaf39b38281020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`, + `"0x01daab749a3dea3f76c52ff0cfc86f0d433ecaf4d20f2ea327042bf5c15bccf847098dc3591fc68bf94d8db6d16cf326808dbf0f44d8e8373e8a7fcaf39b38281020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`, ) expect( @@ -182,7 +182,7 @@ describe('fromHeadlessWebAuthn', () => { hash: '0xdeadbeef', }) expect(signature).toMatchInlineSnapshot( - `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223371322d3777222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d1b3346991a9ad1498e401dc0448e93d1bde113778d442f5bcafc44925cf3121961e9b1c21b054e54fe6c2eec0cd310c8535b7e7dd1f7dd7bf749e6d78154b48120fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`, + `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223371322d3777222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d1b3346991a9ad1498e401dc0448e93d1bde113778d442f5bcafc44925cf3121961e9b1c21b054e54fe6c2eec0cd310c8535b7e7dd1f7dd7bf749e6d78154b48120fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`, ) expect( @@ -275,7 +275,7 @@ describe('signMessage', () => { const account = Account.fromP256(privateKey_p256) const signature = await account.signMessage({ message: 'hello world' }) expect(signature).toMatchInlineSnapshot( - `"0x019e8afd9a5a2a6034a89d1dc09d6351eb83a3bcf3ee55e55973959c3b90b8103726f0de082476045ec872c42efb27ef2159a848df1d5c8326f3ad14dcfd00653220fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`, + `"0x019e8afd9a5a2a6034a89d1dc09d6351eb83a3bcf3ee55e55973959c3b90b8103726f0de082476045ec872c42efb27ef2159a848df1d5c8326f3ad14dcfd00653220fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`, ) expect( @@ -294,7 +294,7 @@ describe('signMessage', () => { }) const signature = await account.signMessage({ message: 'hello world' }) expect(signature).toMatchInlineSnapshot( - `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223265756862744473726b4d72636634416a4a6a4d687975307a43464e4d69436a627a5a544a732d41665767222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d465aa5cd2f5155792a3d5585c059bfacbca733664436aac190c6d2f6c8cd76156a519c9ece3e757a075423f12f87b0dbbb536e158e4b19e6ac94bcc59330843720fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`, + `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223265756862744473726b4d72636634416a4a6a4d687975307a43464e4d69436a627a5a544a732d41665767222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d465aa5cd2f5155792a3d5585c059bfacbca733664436aac190c6d2f6c8cd76156a519c9ece3e757a075423f12f87b0dbbb536e158e4b19e6ac94bcc59330843720fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`, ) expect( @@ -404,7 +404,7 @@ describe('signTypedData', () => { message: { value: 'hello' }, }) expect(signature).toMatchInlineSnapshot( - `"0x01d0e4eba4b8715e90b17d6fae63521ec4f51e119c4f3857ed04120bebc19f61d411606f5b07163c071f4c5e553b9b88ec5d8e0a31c9c3a7472af0b4c3e1bd4c2420fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`, + `"0x01d0e4eba4b8715e90b17d6fae63521ec4f51e119c4f3857ed04120bebc19f61d411606f5b07163c071f4c5e553b9b88ec5d8e0a31c9c3a7472af0b4c3e1bd4c2420fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`, ) expect( @@ -443,7 +443,7 @@ describe('signTypedData', () => { message: { value: 'hello' }, }) expect(signature).toMatchInlineSnapshot( - `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2255444b505432495376767437546f35656436695a70346869485f364c4e6d3570446851646e7878654b5741222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d497b47c010ed378fca3ffba3939edce1a61d994fa0e83c473ef976c9527492f554003f6e898d2b1986aeb8e1731d622d6501f65d09bdefb70d2f72849580ddb020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`, + `"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2255444b505432495376767437546f35656436695a70346869485f364c4e6d3570446851646e7878654b5741222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d497b47c010ed378fca3ffba3939edce1a61d994fa0e83c473ef976c9527492f554003f6e898d2b1986aeb8e1731d622d6501f65d09bdefb70d2f72849580ddb020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`, ) expect( diff --git a/src/tempo/Account.ts b/src/tempo/Account.ts index b4c6886052..34c7c982c4 100644 --- a/src/tempo/Account.ts +++ b/src/tempo/Account.ts @@ -444,10 +444,7 @@ function fromBase(parameters: fromBase.Parameters): Account_base { version: internal_version, }), ) - // Don't need to append magic bytes to secp256k1 signatures as they are - // backwards compatible with existing verification logic. - if (keyType === 'secp256k1') return signature - return Hex.concat(signature, SignatureEnvelope.magicBytes) + return signature } return { diff --git a/src/tempo/chainConfig.test.ts b/src/tempo/chainConfig.test.ts index 87761bf715..02f34c2014 100644 --- a/src/tempo/chainConfig.test.ts +++ b/src/tempo/chainConfig.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' import { accounts, feeToken, getClient } from '~test/tempo/config.js' +import { generatePrivateKey } from '../accounts/generatePrivateKey.js' import { getTransaction, getTransactionReceipt, @@ -12,6 +13,7 @@ import { mainnet, tempoLocalnet } from '../chains/index.js' import { createClient, http } from '../index.js' import { defineChain } from '../utils/chain/defineChain.js' import { hashMessage } from '../utils/index.js' +import * as accessKeyActions from './actions/accessKey.js' import { Account, P256, WebCryptoP256 } from './index.js' const client = getClient({ @@ -361,6 +363,82 @@ describe('verifyHash', () => { ).toBe(false) }) + test('accessKey: valid signature', async () => { + const rootAccount = accounts.at(0)! + const accessKey = Account.fromP256(generatePrivateKey(), { + access: rootAccount, + }) + + await accessKeyActions.authorizeSync(client, { + accessKey, + expiry: Math.floor((Date.now() + 30_000) / 1000), + }) + + const hash = hashMessage('hello world') + const signature = await accessKey.sign({ hash }) + + expect( + await verifyHash(client, { + address: accessKey.address, + hash, + signature, + mode: 'allowAccessKey', + }), + ).toBe(true) + }) + + test('accessKey: invalid signature returns false', async () => { + const rootAccount = accounts.at(0)! + const accessKey = Account.fromP256(generatePrivateKey(), { + access: rootAccount, + }) + + await accessKeyActions.authorizeSync(client, { + accessKey, + expiry: Math.floor((Date.now() + 30_000) / 1000), + }) + + const hash = hashMessage('hello world') + const wrongHash = hashMessage('wrong message') + const signature = await accessKey.sign({ hash }) + + expect( + await verifyHash(client, { + address: accessKey.address, + hash: wrongHash, + signature, + mode: 'allowAccessKey', + }), + ).toBe(false) + }) + + test('accessKey: revoked key returns false', async () => { + const rootAccount = accounts.at(0)! + const accessKey = Account.fromP256(generatePrivateKey(), { + access: rootAccount, + }) + + await accessKeyActions.authorizeSync(client, { + accessKey, + expiry: Math.floor((Date.now() + 30_000) / 1000), + }) + + const hash = hashMessage('hello world') + const signature = await accessKey.sign({ hash }) + + // Revoke the key + await accessKeyActions.revokeSync(client, { accessKey }) + + expect( + await verifyHash(client, { + address: accessKey.address, + hash, + signature, + mode: 'allowAccessKey', + }), + ).toBe(false) + }) + test('behavior: non-tempo chain', async () => { const privateKey = P256.randomPrivateKey() const account = Account.fromP256(privateKey) diff --git a/src/tempo/chainConfig.ts b/src/tempo/chainConfig.ts index ee37a1e668..40672abbae 100644 --- a/src/tempo/chainConfig.ts +++ b/src/tempo/chainConfig.ts @@ -1,3 +1,6 @@ +import * as Address from 'ox/Address' +import * as Hex from 'ox/Hex' +import * as PublicKey from 'ox/PublicKey' import { SignatureEnvelope, type TokenId } from 'ox/tempo' import { getCode } from '../actions/public/getCode.js' import { verifyHash } from '../actions/public/verifyHash.js' @@ -8,8 +11,10 @@ import { defineTransaction } from '../utils/formatters/transaction.js' import { defineTransactionReceipt } from '../utils/formatters/transactionReceipt.js' import { defineTransactionRequest } from '../utils/formatters/transactionRequest.js' import { getAction } from '../utils/getAction.js' +import { keccak256 } from '../utils/hash/keccak256.js' import type { SerializeTransactionFn } from '../utils/transaction/serializeTransaction.js' import type { Account } from './Account.js' +import { getMetadata } from './actions/accessKey.js' import * as Formatters from './Formatters.js' import * as Concurrent from './internal/concurrent.js' import * as Transaction from './Transaction.js' @@ -89,18 +94,53 @@ export const chainConfig = { Transaction.serialize(transaction, signature)) as SerializeTransactionFn, }, async verifyHash(client, parameters) { - const { address, hash, signature } = parameters + const { address, hash, signature, mode } = parameters + + const envelope = (() => { + if (typeof signature !== 'string') return + try { + return SignatureEnvelope.deserialize(signature) + } catch { + return undefined + } + })() // `verifyHash` supports "signature envelopes" (a Tempo proposal) to natively verify arbitrary // envelope-compatible (WebAuthn, P256, etc.) signatures. - // We can directly verify stateless, non-keychain signature envelopes without a - // network request to the chain. - if ( - typeof signature === 'string' && - signature.endsWith(SignatureEnvelope.magicBytes.slice(2)) - ) { - const envelope = SignatureEnvelope.deserialize(signature) - if (envelope.type !== 'keychain') { + if (envelope) { + // Access key (keychain) signature verification: check the key is + // authorized, not expired, and not revoked on the AccountKeychain. + if (envelope?.type === 'keychain' && mode === 'allowAccessKey') { + const accessKeyAddress = Address.fromPublicKey( + PublicKey.from(envelope.inner.publicKey as PublicKey.PublicKey), + ) + + const keyInfo = await getMetadata(client, { + account: address, + accessKey: accessKeyAddress, + blockNumber: parameters.blockNumber, + blockTag: parameters.blockTag, + } as never) + + if (keyInfo.isRevoked) return false + if (keyInfo.expiry <= BigInt(Math.floor(Date.now() / 1000))) + return false + + // For v2 keychain envelopes, the inner signature signs + // keccak256(0x04 || hash || userAddress). + const innerPayload = + envelope.version === 'v2' + ? keccak256(Hex.concat('0x04', hash, address)) + : hash + return SignatureEnvelope.verify(envelope.inner, { + address: accessKeyAddress, + payload: innerPayload, + }) + } + + // Stateless, non-keychain signature envelopes (P256, WebAuthn) can be + // verified directly without a network request. + if (envelope.type === 'p256' || envelope.type === 'webAuthn') { const code = await getCode(client, { address, blockNumber: parameters.blockNumber,