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

Manually write GCP KMS signer with full configuration #1789

Merged
merged 4 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-countries-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/wallets": patch
---

Switch GCP KMS to use signer in package
9 changes: 7 additions & 2 deletions packages/wallets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,11 @@
"@account-abstraction/utils": "^0.5.0",
"@blocto/sdk": "^0.5.4",
"@coinbase/wallet-sdk": "^3.7.1",
"@google-cloud/kms": "3.0.1",
"@magic-ext/connect": "^6.7.2",
"@magic-ext/oauth": "^7.6.2",
"@magic-sdk/provider": "^13.6.2",
"@metamask/eth-sig-util": "^4.0.0",
"@paperxyz/embedded-wallet-service-sdk": "^1.2.4",
"@paperxyz/sdk-common-utilities": "^0.1.0",
"@safe-global/safe-core-sdk": "^3.3.4",
Expand All @@ -388,10 +390,14 @@
"@walletconnect/types": "^2.9.1",
"@walletconnect/utils": "^2.10.2",
"@walletconnect/web3wallet": "^1.8.7",
"asn1.js": "5.4.1",
"bn.js": "5.2.0",
"buffer": "^6.0.3",
"crypto-js": "^4.1.1",
"eth-provider": "^0.13.6",
"ethereumjs-util": "^7.1.3",
"eventemitter3": "^5.0.1",
"key-encoder": "2.0.3",
"magic-sdk": "^13.6.2",
"web3-core": "1.5.2"
},
Expand All @@ -401,7 +407,6 @@
"bs58": "^5.0.0",
"ethers": "^5.7.2",
"ethers-aws-kms-signer": "^1.3.2",
"ethers-gcp-kms-signer": "^1.1.6",
"tweetnacl": "^1.0.3"
},
"peerDependenciesMeta": {
Expand Down Expand Up @@ -432,6 +437,7 @@
"@noble/ed25519": "^1.7.1",
"@preconstruct/cli": "2.7.0",
"@thirdweb-dev/tsconfig": "workspace:*",
"@types/bn.js": "^5.1.1",
"@types/crypto-js": "^4.1.1",
"abitype": "^0.2.5",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
Expand All @@ -442,7 +448,6 @@
"ethereum-provider": "^0.7.7",
"ethers": "^5.7.2",
"ethers-aws-kms-signer": "^1.3.2",
"ethers-gcp-kms-signer": "^1.1.6",
"tweetnacl": "^1.0.3",
"typescript": "^5.1.6"
},
Expand Down
1 change: 1 addition & 0 deletions packages/wallets/src/evm/connectors/gcp-kms/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'asn1.js';
149 changes: 149 additions & 0 deletions packages/wallets/src/evm/connectors/gcp-kms/signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* All code forked from ethers-gcp-kms-signer repository:
* https://github.com/openlawteam/ethers-gcp-kms-signer/tree/master
*
* We had to fork this code because the repository forces GOOGLE_APPLICATION_CREDENTIALS
* to be set with environment variables, but we need a path to pass them directly.
*/

import {
MessageTypes,
SignTypedDataVersion,
TypedDataV1,
TypedMessage,
typedSignatureHash,
TypedDataUtils,
} from "@metamask/eth-sig-util";

import { ethers, UnsignedTransaction } from "ethers";
import { bufferToHex } from "ethereumjs-util";
import {
getPublicKey,
getEthereumAddress,
requestKmsSignature,
determineCorrectV,
} from "./utils/gcp-kms-utils";
import { validateVersion } from "./utils/signature-utils";

export const TypedDataVersion = SignTypedDataVersion;

export interface GcpKmsSignerCredentials {
projectId: string;
locationId: string;
keyRingId: string;
keyId: string;
keyVersion: string;
applicationCredentialEmail?: string;
applicationCredentialPrivateKey?: string;
}

export class GcpKmsSigner extends ethers.Signer {
// @ts-expect-error Allow defineReadOnly to set this property
kmsCredentials: GcpKmsSignerCredentials;
// @ts-expect-error Allow defineReadOnly to set this property
ethereumAddress: string;

constructor(
kmsCredentials: GcpKmsSignerCredentials,
provider?: ethers.providers.Provider,
) {
super();
// @ts-expect-error Allow passing null here
ethers.utils.defineReadOnly(this, "provider", provider || null);
ethers.utils.defineReadOnly(this, "kmsCredentials", kmsCredentials);
}

async getAddress(): Promise<string> {
if (this.ethereumAddress === undefined) {
const key = await getPublicKey(this.kmsCredentials);
this.ethereumAddress = getEthereumAddress(key);
}
return Promise.resolve(this.ethereumAddress);
}

async _signDigest(digestString: string): Promise<string> {
const digestBuffer = Buffer.from(ethers.utils.arrayify(digestString));
const sig = await requestKmsSignature(digestBuffer, this.kmsCredentials);
const ethAddr = await this.getAddress();
const { v } = determineCorrectV(digestBuffer, sig.r, sig.s, ethAddr);
return ethers.utils.joinSignature({
v,
r: `0x${sig.r.toString("hex")}`,
s: `0x${sig.s.toString("hex")}`,
});
}

async signMessage(message: string | ethers.utils.Bytes): Promise<string> {
return this._signDigest(ethers.utils.hashMessage(message));
}

/**
* Original implementation takes into account the private key, but here we use the private
* key from the GCP KMS, so we don't need to provide the PK as signature option.
* Source code: https://github.com/MetaMask/eth-sig-util/blob/main/src/sign-typed-data.ts#L510
* .
* Sign typed data according to EIP-712. The signing differs based upon the `version`.
*
* V1 is based upon [an early version of EIP-712](https://github.com/ethereum/EIPs/pull/712/commits/21abe254fe0452d8583d5b132b1d7be87c0439ca)
* that lacked some later security improvements, and should generally be neglected in favor of
* later versions.
*
* V3 is based on [EIP-712](https://eips.ethereum.org/EIPS/eip-712), except that arrays and
* recursive data structures are not supported.
*
* V4 is based on [EIP-712](https://eips.ethereum.org/EIPS/eip-712), and includes full support of
* arrays and recursive data structures.
*
* @param options - The signing options.
* @param options.data - The typed data to sign.
* @param options.version - The signing version to use.
* @returns The '0x'-prefixed hex encoded signature.
*/
async signTypedData<V extends SignTypedDataVersion, T extends MessageTypes>({
data,
version,
}: {
data: V extends "V1" ? TypedDataV1 : TypedMessage<T>;
version: V;
}): Promise<string> {
validateVersion(version);

if (data === null || data === undefined) {
throw new Error("Missing data parameter");
}

let messageSignature: Promise<string>;
if (version === SignTypedDataVersion.V1) {
messageSignature = this._signDigest(
typedSignatureHash(data as TypedDataV1),
);
} else {
const eip712Hash: Buffer = TypedDataUtils.eip712Hash(
data as TypedMessage<T>,
version as SignTypedDataVersion.V3 | SignTypedDataVersion.V4,
);
messageSignature = this._signDigest(bufferToHex(eip712Hash));
}
return messageSignature;
}

async signTransaction(
transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>,
): Promise<string> {
const unsignedTx = await ethers.utils.resolveProperties(transaction);
const serializedTx = ethers.utils.serializeTransaction(
<UnsignedTransaction>unsignedTx,
);
const transactionSignature = await this._signDigest(
ethers.utils.keccak256(serializedTx),
);
return ethers.utils.serializeTransaction(
<UnsignedTransaction>unsignedTx,
transactionSignature,
);
}

connect(provider: ethers.providers.Provider): GcpKmsSigner {
return new GcpKmsSigner(this.kmsCredentials, provider);
}
}
182 changes: 182 additions & 0 deletions packages/wallets/src/evm/connectors/gcp-kms/utils/gcp-kms-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { ethers } from "ethers";
import { KeyManagementServiceClient } from "@google-cloud/kms";
import * as asn1 from "asn1.js";
import BN from "bn.js";
import KeyEncoder from "key-encoder";
import type { GcpKmsSignerCredentials } from "../signer";

const keyEncoder = new KeyEncoder("secp256k1");

/* this asn1.js library has some funky things going on */
/* eslint-disable func-names */
const EcdsaSigAsnParse: {
decode: (asnStringBuffer: Buffer, format: "der") => { r: BN; s: BN };
// eslint-disable-next-line better-tree-shaking/no-top-level-side-effects
} = asn1.define("EcdsaSig", function (this: any) {
// parsing this according to https://tools.ietf.org/html/rfc3279#section-2.2.3
this.seq().obj(this.key("r").int(), this.key("s").int());
});
// eslint-disable-next-line better-tree-shaking/no-top-level-side-effects
const EcdsaPubKey = asn1.define("EcdsaPubKey", function (this: any) {
// parsing this according to https://tools.ietf.org/html/rfc5480#section-2
this.seq().obj(
this.key("algo").seq().obj(this.key("a").objid(), this.key("b").objid()),
this.key("pubKey").bitstr(),
);
});
/* eslint-enable func-names */

function getClientCredentials(kmsCredentials: GcpKmsSignerCredentials) {
if (
kmsCredentials.applicationCredentialEmail &&
kmsCredentials.applicationCredentialPrivateKey
) {
return {
credentials: {
client_email: kmsCredentials.applicationCredentialEmail,
private_key: kmsCredentials.applicationCredentialPrivateKey.replace(
/\\n/gm,
"\n",
),
},
};
}

const applicationCredentialEmail =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.GOOGLE_APPLICATION_CREDENTIAL_EMAIL;
const applicationCredentialPrivateKey =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.GOOGLE_APPLICATION_CREDENTIAL_PRIVATE_KEY;
return applicationCredentialEmail && applicationCredentialPrivateKey
? {
credentials: {
client_email: applicationCredentialEmail,
private_key: applicationCredentialPrivateKey.replace(/\\n/gm, "\n"),
},
}
: {};
}

export async function sign(
digest: Buffer,
kmsCredentials: GcpKmsSignerCredentials,
) {
const kms = new KeyManagementServiceClient(
getClientCredentials(kmsCredentials),
);
const versionName = kms.cryptoKeyVersionPath(
kmsCredentials.projectId,
kmsCredentials.locationId,
kmsCredentials.keyRingId,
kmsCredentials.keyId,
kmsCredentials.keyVersion,
);
const [asymmetricSignResponse] = await kms.asymmetricSign({
name: versionName,
digest: {
sha256: digest,
},
});
return asymmetricSignResponse;
}

export const getPublicKey = async (kmsCredentials: GcpKmsSignerCredentials) => {
const kms = new KeyManagementServiceClient(
getClientCredentials(kmsCredentials),
);
const versionName = kms.cryptoKeyVersionPath(
kmsCredentials.projectId,
kmsCredentials.locationId,
kmsCredentials.keyRingId,
kmsCredentials.keyId,
kmsCredentials.keyVersion,
);
const [publicKey] = await kms.getPublicKey({
name: versionName,
});
if (!publicKey || !publicKey.pem) {
throw new Error(`Can not find key: ${kmsCredentials.keyId}`);
}

// GCP KMS returns the public key in pem format,
// so we need to encode it to der format, and return the hex buffer.
const der = keyEncoder.encodePublic(publicKey.pem, "pem", "der");
return Buffer.from(der, "hex");
};

export function getEthereumAddress(publicKey: Buffer): string {
// The public key here is a hex der ASN1 encoded in a format according to
// https://tools.ietf.org/html/rfc5480#section-2
// I used https://lapo.it/asn1js to figure out how to parse this
// and defined the schema in the EcdsaPubKey object.
const res = EcdsaPubKey.decode(publicKey, "der");
const pubKeyBuffer: Buffer = res.pubKey.data;

// The raw format public key starts with a `04` prefix that needs to be removed
// more info: https://www.oreilly.com/library/view/mastering-ethereum/9781491971932/ch04.html
// const pubStr = publicKey.toString();
const pubFormatted = pubKeyBuffer.slice(1, pubKeyBuffer.length);

// keccak256 hash of publicKey
const address = ethers.utils.keccak256(pubFormatted);
// take last 20 bytes as ethereum address
const EthAddr = `0x${address.slice(-40)}`;
return EthAddr;
}

export function findEthereumSig(signature: Buffer) {
const decoded = EcdsaSigAsnParse.decode(signature, "der");
const { r, s } = decoded;

const secp256k1N = new BN(
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
16,
); // max value on the curve
const secp256k1halfN = secp256k1N.div(new BN(2)); // half of the curve
// Because of EIP-2 not all elliptic curve signatures are accepted
// the value of s needs to be SMALLER than half of the curve
// i.e. we need to flip s if it's greater than half of the curve
// if s is less than half of the curve, we're on the "good" side of the curve, we can just return
return { r, s: s.gt(secp256k1halfN) ? secp256k1N.sub(s) : s };
}

export async function requestKmsSignature(
plaintext: Buffer,
kmsCredentials: GcpKmsSignerCredentials,
) {
const response = await sign(plaintext, kmsCredentials);
if (!response || !response.signature) {
throw new Error(`GCP KMS call failed`);
}
return findEthereumSig(response.signature as Buffer);
}

function recoverPubKeyFromSig(msg: Buffer, r: BN, s: BN, v: number) {
return ethers.utils.recoverAddress(`0x${msg.toString("hex")}`, {
r: `0x${r.toString("hex")}`,
s: `0x${s.toString("hex")}`,
v,
});
}

export function determineCorrectV(
msg: Buffer,
r: BN,
s: BN,
expectedEthAddr: string,
) {
// This is the wrapper function to find the right v value
// There are two matching signatures on the elliptic curve
// we need to find the one that matches to our public key
// it can be v = 27 or v = 28
let v = 27;
let pubKey = recoverPubKeyFromSig(msg, r, s, v);
if (pubKey.toLowerCase() !== expectedEthAddr.toLowerCase()) {
// if the pub key for v = 27 does not match
// it has to be v = 28
v = 28;
pubKey = recoverPubKeyFromSig(msg, r, s, v);
}
return { pubKey, v };
}
Loading