Skip to content

Commit

Permalink
feat: viem account from gcp hsm (#2)
Browse files Browse the repository at this point in the history
Full working implementation with support for signing transactions,
messages and typed data.

Ported from
https://github.com/celo-org/developer-tooling/tree/0c61e7e02c741fe10ecd1d733a33692d324cdc82/packages/sdk/wallets/wallet-hsm-gcp

### Send script run

```
❯ GCLOUD_PROJECT=valora-viem-hsm-test yarn ts-node ./scripts/send.ts
yarn run v1.22.21
$ /Users/jean/src/github.com/valora-inc/viem-account-hsm-gcp/node_modules/.bin/ts-node ./scripts/send.ts
Viem HSM Account: 0x6AD01Ac6841b67f27DC1A039FefBF5804003d6a4
Balance: 0.997207 CELO
Sending 0.001 CELO from Viem HSM Account...
Waiting for transaction hash: 0x834f5cfc42ddfe293ec81d5e154c1fac37cf3e5772a6d322e2d99c1b6e503e50
TX status: success
✨  Done in 12.94s.
```

Next up: add e2e test for CI, README, automated publish to NPM

Fixes RET-1037
  • Loading branch information
jeanregisser authored Apr 19, 2024
1 parent 5c6e784 commit 951d233
Show file tree
Hide file tree
Showing 8 changed files with 1,044 additions and 105 deletions.
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@
"deploy": "echo 'Deployed!'",
"knip": "knip"
},
"peerDependencies": {
"viem": "^2.9.20"
},
"dependencies": {
"@google-cloud/kms": "^4.2.0",
"@noble/curves": "^1.4.0",
"asn1js": "^3.0.5"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/shelljs": "^0.8.15",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@valora/eslint-config-typescript": "^1.0.2",
"@valora/prettier-config": "^0.0.1",
Expand All @@ -40,10 +47,10 @@
"jest": "^29.7.0",
"knip": "^5.2.2",
"prettier": "^3.2.5",
"shelljs": "^0.8.5",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.4"
"typescript": "^5.4.4",
"viem": "^2.9.20"
},
"prettier": "@valora/prettier-config"
}
8 changes: 0 additions & 8 deletions scripts/example.ts

This file was deleted.

48 changes: 48 additions & 0 deletions scripts/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* eslint-disable no-console */
import { gcpHsmToAccount } from '../src'
import {
createWalletClient,
formatUnits,
http,
parseEther,
publicActions,
} from 'viem'
import { celoAlfajores } from 'viem/chains'

async function main() {
const hsmKeyVersion =
'projects/valora-viem-hsm-test/locations/global/keyRings/test/cryptoKeys/hsm/cryptoKeyVersions/1'

const viemHsmAccount = await gcpHsmToAccount({ hsmKeyVersion })
const { address } = viemHsmAccount
console.log('Viem HSM Account:', address)

const client = createWalletClient({
account: viemHsmAccount,
chain: celoAlfajores,
transport: http(),
}).extend(publicActions)

const balance = await client.getBalance({ address })
console.log(`Balance: ${formatUnits(balance, 18)} CELO`)

console.log('Sending 0.001 CELO from Viem HSM Account...')
const hash = await client.sendTransaction({
to: viemHsmAccount.address,
value: parseEther('0.001'),
})

console.log('Waiting for transaction hash:', hash)

const receipt = await client.waitForTransactionReceipt({ hash })
console.log('TX status:', receipt.status)

if (receipt.status !== 'success') {
throw new Error('Transaction failed!')
}
}

main().catch((error) => {
console.error(error)
process.exit(1)
})
1 change: 1 addition & 0 deletions scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"skipLibCheck": true,
"moduleResolution": "node",
Expand Down
4 changes: 0 additions & 4 deletions src/data.json

This file was deleted.

205 changes: 201 additions & 4 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,204 @@
import { main } from './index'
import { KeyManagementServiceClient } from '@google-cloud/kms'
import { secp256k1 } from '@noble/curves/secp256k1'
import {
Hex,
hexToBytes,
parseEther,
parseGwei,
recoverTransactionAddress,
recoverTypedDataAddress,
toHex,
verifyMessage,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import * as asn1 from 'asn1js'
import { gcpHsmToAccount } from './index'

describe(main, () => {
it("should return 'Hello, world!'", () => {
expect(main()).toBe('Hello, world!')
const PRIVATE_KEY1 =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
const ACCOUNT1 = privateKeyToAccount(PRIVATE_KEY1)
const PRIVATE_KEY2 =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890fdeccc'
const ACCOUNT2 = privateKeyToAccount(PRIVATE_KEY2)

const TYPED_DATA = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
],
},
primaryType: 'Mail',
domain: {
name: 'Ether Mail',
version: '1',
chainId: 1n,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
},
message: {
from: {
name: 'Cow',
wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
},
to: {
name: 'Bob',
wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
},
contents: 'Hello, Bob!',
},
} as const

const MOCK_GCP_HSM_KEY_NAME =
'projects/gcp-test-account/locations/global/keyRings/test/cryptoKeys/hsm/cryptoKeyVersions/1'

const MOCK_KEYS: Map<string, string> = new Map([
[MOCK_GCP_HSM_KEY_NAME, PRIVATE_KEY1],
])

function derFromPublicKey(publicKey: Hex): Buffer {
const sequence = new asn1.Sequence()
const values = sequence.valueBlock.value
for (const i of [0, 1]) {
values.push(
new asn1.Integer({
value: i,
}),
)
}
const value = values[1] as asn1.BitString
value.valueBlock.valueHexView = hexToBytes(publicKey)
return Buffer.from(sequence.toBER(false))
}

const mockKmsClient = {
getPublicKey: async ({ name: versionName }: { name: string }) => {
const privateKey = MOCK_KEYS.get(versionName)
if (!privateKey) {
throw new Error(`Unable to locate key: '${versionName}'`)
}

const pubKey = secp256k1.getPublicKey(privateKey.slice(2), false)
const derKey = derFromPublicKey(toHex(pubKey))
const pem = `-----BEGIN PUBLIC KEY-----\n${derKey
.toString('base64')
.match(/.{0,64}/g)!
.join('\n')}-----END PUBLIC KEY-----\n`
return [{ pem }]
},
asymmetricSign: async ({
name,
digest,
}: {
name: string
digest: { sha256: Buffer }
}) => {
const privateKey = MOCK_KEYS.get(name)
if (!privateKey) {
throw new Error(`Unable to locate key: ${name}`)
}

const signature = secp256k1.sign(digest.sha256, privateKey.slice(2))

return [{ signature: signature.toDERRawBytes() }]
},
} as unknown as KeyManagementServiceClient

describe('gcpHsmToAccount', () => {
it('returns a valid viem account when given a known hsm key', async () => {
const gcpHsmAccount = await gcpHsmToAccount({
hsmKeyVersion: MOCK_GCP_HSM_KEY_NAME,
kmsClient: mockKmsClient,
})
expect(gcpHsmAccount).toEqual({
address: ACCOUNT1.address,
publicKey: ACCOUNT1.publicKey,
signMessage: expect.any(Function),
signTransaction: expect.any(Function),
signTypedData: expect.any(Function),
source: 'gcpHsm',
type: 'local',
})
})

it('throws an error when given an unknown hsm key', async () => {
await expect(
gcpHsmToAccount({
hsmKeyVersion: 'an-unknown-key',
kmsClient: mockKmsClient,
}),
).rejects.toThrow("Unable to locate key: 'an-unknown-key'")
})

it('signs a message', async () => {
const gcpHsmAccount = await gcpHsmToAccount({
hsmKeyVersion: MOCK_GCP_HSM_KEY_NAME,
kmsClient: mockKmsClient,
})

const message = 'hello world'
const signature = await gcpHsmAccount.signMessage({ message })

expect(signature).toBe(
'0x08c183d08a952dcd603148842de1d7844a1a6d72a3761840ebe10a570240821e3348c9296af823c8f4de5258f997fa35ee4ad8fce79cda929021f6976d0c10431c',
)
await expect(
verifyMessage({
address: ACCOUNT1.address,
message,
signature,
}),
).resolves.toBeTruthy()
})

it('signs a transaction', async () => {
const gcpHsmAccount = await gcpHsmToAccount({
hsmKeyVersion: MOCK_GCP_HSM_KEY_NAME,
kmsClient: mockKmsClient,
})
const signedTx = await gcpHsmAccount.signTransaction({
chainId: 1,
maxFeePerGas: parseGwei('20'),
gas: 21000n,
to: ACCOUNT2.address,
value: parseEther('1'),
})

expect(signedTx).toBe(
'0x02f86f0180808504a817c80082520894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000080c080a045b0a758fd31e75c9f8558aa5eb2aee359693d781c2b2f8ef000d9bfefc8e3e7a004d6440b24582611c77b93113b5c6ac45d0ade91e8067ef8867a088e227be8d9',
)
await expect(
recoverTransactionAddress({
serializedTransaction: signedTx,
}),
).resolves.toBe(ACCOUNT1.address)
})

it('signs typed data', async () => {
const gcpHsmAccount = await gcpHsmToAccount({
hsmKeyVersion: MOCK_GCP_HSM_KEY_NAME,
kmsClient: mockKmsClient,
})
const signature = await gcpHsmAccount.signTypedData(TYPED_DATA)
expect(signature).toBe(
'0x51a454925c2ff4cad0a09cc64fc970685a17f39b2c3a843323f0cc08942d413d15e1ee8c7ff2e12e85eaf1f887cadfbb20b270a579f0945f30de2a73cad4d8ce1c',
)

await expect(
recoverTypedDataAddress({
...TYPED_DATA,
signature,
}),
).resolves.toBe(ACCOUNT1.address)
})
})
Loading

0 comments on commit 951d233

Please sign in to comment.