Skip to content

Commit 3aa680d

Browse files
jxomampcode-com
andauthored
feat(tempo): add access key signature verification to verifyHash (#4432)
* feat(tempo): add access key signature verification to verifyHash Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d4188-44cf-7387-8387-dc2bd7f7b883 * chore: fmt --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 2ee22ab commit 3aa680d

6 files changed

Lines changed: 140 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"viem": patch
3+
---
4+
5+
**viem/tempo:** Added access key signature verification support to `verifyHash` via `mode: 'allowAccessKey'`.

src/actions/public/verifyHash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export type VerifyHashParameters = Pick<
8080
/** @deprecated use `erc6492VerifierAddress` instead. */
8181
universalSignatureVerifierAddress?: Address | undefined
8282
/** Chooses which verification path to try first before falling back. */
83-
mode?: 'auto' | 'eoa' | undefined
83+
mode?: 'auto' | 'eoa' | (string & {}) | undefined
8484
} & OneOf<{ factory: Address; factoryData: Hex } | {}>
8585

8686
export type VerifyHashReturnType = boolean

src/tempo/Account.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ describe('fromP256', () => {
106106
hash: '0xdeadbeef',
107107
})
108108
expect(signature).toMatchInlineSnapshot(
109-
`"0x01daab749a3dea3f76c52ff0cfc86f0d433ecaf4d20f2ea327042bf5c15bccf847098dc3591fc68bf94d8db6d16cf326808dbf0f44d8e8373e8a7fcaf39b38281020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`,
109+
`"0x01daab749a3dea3f76c52ff0cfc86f0d433ecaf4d20f2ea327042bf5c15bccf847098dc3591fc68bf94d8db6d16cf326808dbf0f44d8e8373e8a7fcaf39b38281020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`,
110110
)
111111

112112
expect(
@@ -182,7 +182,7 @@ describe('fromHeadlessWebAuthn', () => {
182182
hash: '0xdeadbeef',
183183
})
184184
expect(signature).toMatchInlineSnapshot(
185-
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223371322d3777222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d1b3346991a9ad1498e401dc0448e93d1bde113778d442f5bcafc44925cf3121961e9b1c21b054e54fe6c2eec0cd310c8535b7e7dd1f7dd7bf749e6d78154b48120fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`,
185+
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223371322d3777222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d1b3346991a9ad1498e401dc0448e93d1bde113778d442f5bcafc44925cf3121961e9b1c21b054e54fe6c2eec0cd310c8535b7e7dd1f7dd7bf749e6d78154b48120fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`,
186186
)
187187

188188
expect(
@@ -275,7 +275,7 @@ describe('signMessage', () => {
275275
const account = Account.fromP256(privateKey_p256)
276276
const signature = await account.signMessage({ message: 'hello world' })
277277
expect(signature).toMatchInlineSnapshot(
278-
`"0x019e8afd9a5a2a6034a89d1dc09d6351eb83a3bcf3ee55e55973959c3b90b8103726f0de082476045ec872c42efb27ef2159a848df1d5c8326f3ad14dcfd00653220fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`,
278+
`"0x019e8afd9a5a2a6034a89d1dc09d6351eb83a3bcf3ee55e55973959c3b90b8103726f0de082476045ec872c42efb27ef2159a848df1d5c8326f3ad14dcfd00653220fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`,
279279
)
280280

281281
expect(
@@ -294,7 +294,7 @@ describe('signMessage', () => {
294294
})
295295
const signature = await account.signMessage({ message: 'hello world' })
296296
expect(signature).toMatchInlineSnapshot(
297-
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223265756862744473726b4d72636634416a4a6a4d687975307a43464e4d69436a627a5a544a732d41665767222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d465aa5cd2f5155792a3d5585c059bfacbca733664436aac190c6d2f6c8cd76156a519c9ece3e757a075423f12f87b0dbbb536e158e4b19e6ac94bcc59330843720fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`,
297+
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a223265756862744473726b4d72636634416a4a6a4d687975307a43464e4d69436a627a5a544a732d41665767222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d465aa5cd2f5155792a3d5585c059bfacbca733664436aac190c6d2f6c8cd76156a519c9ece3e757a075423f12f87b0dbbb536e158e4b19e6ac94bcc59330843720fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`,
298298
)
299299

300300
expect(
@@ -404,7 +404,7 @@ describe('signTypedData', () => {
404404
message: { value: 'hello' },
405405
})
406406
expect(signature).toMatchInlineSnapshot(
407-
`"0x01d0e4eba4b8715e90b17d6fae63521ec4f51e119c4f3857ed04120bebc19f61d411606f5b07163c071f4c5e553b9b88ec5d8e0a31c9c3a7472af0b4c3e1bd4c2420fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240007777777777777777777777777777777777777777777777777777777777777777"`,
407+
`"0x01d0e4eba4b8715e90b17d6fae63521ec4f51e119c4f3857ed04120bebc19f61d411606f5b07163c071f4c5e553b9b88ec5d8e0a31c9c3a7472af0b4c3e1bd4c2420fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c81224000"`,
408408
)
409409

410410
expect(
@@ -443,7 +443,7 @@ describe('signTypedData', () => {
443443
message: { value: 'hello' },
444444
})
445445
expect(signature).toMatchInlineSnapshot(
446-
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2255444b505432495376767437546f35656436695a70346869485f364c4e6d3570446851646e7878654b5741222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d497b47c010ed378fca3ffba3939edce1a61d994fa0e83c473ef976c9527492f554003f6e898d2b1986aeb8e1731d622d6501f65d09bdefb70d2f72849580ddb020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c8122407777777777777777777777777777777777777777777777777777777777777777"`,
446+
`"0x0249960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d976305000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2255444b505432495376767437546f35656436695a70346869485f364c4e6d3570446851646e7878654b5741222c226f726967696e223a22687474703a2f2f6c6f63616c686f7374222c2263726f73734f726967696e223a66616c73657d497b47c010ed378fca3ffba3939edce1a61d994fa0e83c473ef976c9527492f554003f6e898d2b1986aeb8e1731d622d6501f65d09bdefb70d2f72849580ddb020fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240"`,
447447
)
448448

449449
expect(

src/tempo/Account.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -444,10 +444,7 @@ function fromBase(parameters: fromBase.Parameters): Account_base {
444444
version: internal_version,
445445
}),
446446
)
447-
// Don't need to append magic bytes to secp256k1 signatures as they are
448-
// backwards compatible with existing verification logic.
449-
if (keyType === 'secp256k1') return signature
450-
return Hex.concat(signature, SignatureEnvelope.magicBytes)
447+
return signature
451448
}
452449

453450
return {

src/tempo/chainConfig.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from 'vitest'
22
import { accounts, feeToken, getClient } from '~test/tempo/config.js'
3+
import { generatePrivateKey } from '../accounts/generatePrivateKey.js'
34
import {
45
getTransaction,
56
getTransactionReceipt,
@@ -12,6 +13,7 @@ import { mainnet, tempoLocalnet } from '../chains/index.js'
1213
import { createClient, http } from '../index.js'
1314
import { defineChain } from '../utils/chain/defineChain.js'
1415
import { hashMessage } from '../utils/index.js'
16+
import * as accessKeyActions from './actions/accessKey.js'
1517
import { Account, P256, WebCryptoP256 } from './index.js'
1618

1719
const client = getClient({
@@ -361,6 +363,82 @@ describe('verifyHash', () => {
361363
).toBe(false)
362364
})
363365

366+
test('accessKey: valid signature', async () => {
367+
const rootAccount = accounts.at(0)!
368+
const accessKey = Account.fromP256(generatePrivateKey(), {
369+
access: rootAccount,
370+
})
371+
372+
await accessKeyActions.authorizeSync(client, {
373+
accessKey,
374+
expiry: Math.floor((Date.now() + 30_000) / 1000),
375+
})
376+
377+
const hash = hashMessage('hello world')
378+
const signature = await accessKey.sign({ hash })
379+
380+
expect(
381+
await verifyHash(client, {
382+
address: accessKey.address,
383+
hash,
384+
signature,
385+
mode: 'allowAccessKey',
386+
}),
387+
).toBe(true)
388+
})
389+
390+
test('accessKey: invalid signature returns false', async () => {
391+
const rootAccount = accounts.at(0)!
392+
const accessKey = Account.fromP256(generatePrivateKey(), {
393+
access: rootAccount,
394+
})
395+
396+
await accessKeyActions.authorizeSync(client, {
397+
accessKey,
398+
expiry: Math.floor((Date.now() + 30_000) / 1000),
399+
})
400+
401+
const hash = hashMessage('hello world')
402+
const wrongHash = hashMessage('wrong message')
403+
const signature = await accessKey.sign({ hash })
404+
405+
expect(
406+
await verifyHash(client, {
407+
address: accessKey.address,
408+
hash: wrongHash,
409+
signature,
410+
mode: 'allowAccessKey',
411+
}),
412+
).toBe(false)
413+
})
414+
415+
test('accessKey: revoked key returns false', async () => {
416+
const rootAccount = accounts.at(0)!
417+
const accessKey = Account.fromP256(generatePrivateKey(), {
418+
access: rootAccount,
419+
})
420+
421+
await accessKeyActions.authorizeSync(client, {
422+
accessKey,
423+
expiry: Math.floor((Date.now() + 30_000) / 1000),
424+
})
425+
426+
const hash = hashMessage('hello world')
427+
const signature = await accessKey.sign({ hash })
428+
429+
// Revoke the key
430+
await accessKeyActions.revokeSync(client, { accessKey })
431+
432+
expect(
433+
await verifyHash(client, {
434+
address: accessKey.address,
435+
hash,
436+
signature,
437+
mode: 'allowAccessKey',
438+
}),
439+
).toBe(false)
440+
})
441+
364442
test('behavior: non-tempo chain', async () => {
365443
const privateKey = P256.randomPrivateKey()
366444
const account = Account.fromP256(privateKey)

src/tempo/chainConfig.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as Address from 'ox/Address'
2+
import * as Hex from 'ox/Hex'
3+
import * as PublicKey from 'ox/PublicKey'
14
import { SignatureEnvelope, type TokenId } from 'ox/tempo'
25
import { getCode } from '../actions/public/getCode.js'
36
import { verifyHash } from '../actions/public/verifyHash.js'
@@ -8,8 +11,10 @@ import { defineTransaction } from '../utils/formatters/transaction.js'
811
import { defineTransactionReceipt } from '../utils/formatters/transactionReceipt.js'
912
import { defineTransactionRequest } from '../utils/formatters/transactionRequest.js'
1013
import { getAction } from '../utils/getAction.js'
14+
import { keccak256 } from '../utils/hash/keccak256.js'
1115
import type { SerializeTransactionFn } from '../utils/transaction/serializeTransaction.js'
1216
import type { Account } from './Account.js'
17+
import { getMetadata } from './actions/accessKey.js'
1318
import * as Formatters from './Formatters.js'
1419
import * as Concurrent from './internal/concurrent.js'
1520
import * as Transaction from './Transaction.js'
@@ -89,18 +94,53 @@ export const chainConfig = {
8994
Transaction.serialize(transaction, signature)) as SerializeTransactionFn,
9095
},
9196
async verifyHash(client, parameters) {
92-
const { address, hash, signature } = parameters
97+
const { address, hash, signature, mode } = parameters
98+
99+
const envelope = (() => {
100+
if (typeof signature !== 'string') return
101+
try {
102+
return SignatureEnvelope.deserialize(signature)
103+
} catch {
104+
return undefined
105+
}
106+
})()
93107

94108
// `verifyHash` supports "signature envelopes" (a Tempo proposal) to natively verify arbitrary
95109
// envelope-compatible (WebAuthn, P256, etc.) signatures.
96-
// We can directly verify stateless, non-keychain signature envelopes without a
97-
// network request to the chain.
98-
if (
99-
typeof signature === 'string' &&
100-
signature.endsWith(SignatureEnvelope.magicBytes.slice(2))
101-
) {
102-
const envelope = SignatureEnvelope.deserialize(signature)
103-
if (envelope.type !== 'keychain') {
110+
if (envelope) {
111+
// Access key (keychain) signature verification: check the key is
112+
// authorized, not expired, and not revoked on the AccountKeychain.
113+
if (envelope?.type === 'keychain' && mode === 'allowAccessKey') {
114+
const accessKeyAddress = Address.fromPublicKey(
115+
PublicKey.from(envelope.inner.publicKey as PublicKey.PublicKey),
116+
)
117+
118+
const keyInfo = await getMetadata(client, {
119+
account: address,
120+
accessKey: accessKeyAddress,
121+
blockNumber: parameters.blockNumber,
122+
blockTag: parameters.blockTag,
123+
} as never)
124+
125+
if (keyInfo.isRevoked) return false
126+
if (keyInfo.expiry <= BigInt(Math.floor(Date.now() / 1000)))
127+
return false
128+
129+
// For v2 keychain envelopes, the inner signature signs
130+
// keccak256(0x04 || hash || userAddress).
131+
const innerPayload =
132+
envelope.version === 'v2'
133+
? keccak256(Hex.concat('0x04', hash, address))
134+
: hash
135+
return SignatureEnvelope.verify(envelope.inner, {
136+
address: accessKeyAddress,
137+
payload: innerPayload,
138+
})
139+
}
140+
141+
// Stateless, non-keychain signature envelopes (P256, WebAuthn) can be
142+
// verified directly without a network request.
143+
if (envelope.type === 'p256' || envelope.type === 'webAuthn') {
104144
const code = await getCode(client, {
105145
address,
106146
blockNumber: parameters.blockNumber,

0 commit comments

Comments
 (0)