Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: viem account from gcp hsm #2

Merged
merged 13 commits into from
Apr 19, 2024
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 asn1FromPublicKey(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 asn1Key = asn1FromPublicKey(toHex(pubKey))
const pem = `-----BEGIN PUBLIC KEY-----\n${asn1Key
.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