From 7d28d0b3a964010f59510a41d3eab80cc68bf6eb Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 9 Oct 2023 15:08:31 +0200 Subject: [PATCH] feat: ledger thorchain support --- .../hdwallet-ledger-webusb/src/transport.ts | 7 + packages/hdwallet-ledger/package.json | 4 +- packages/hdwallet-ledger/src/index.ts | 1 + packages/hdwallet-ledger/src/ledger.ts | 6 + .../hdwallet-ledger/src/thorchain/common.ts | 113 ++++++ .../hdwallet-ledger/src/thorchain/helpers.ts | 68 ++++ .../src/thorchain/hw-app-thor.ts | 331 ++++++++++++++++++ .../hdwallet-ledger/src/thorchain/index.ts | 26 ++ packages/hdwallet-ledger/src/transport.ts | 2 +- yarn.lock | 7 + 10 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 packages/hdwallet-ledger/src/thorchain/common.ts create mode 100644 packages/hdwallet-ledger/src/thorchain/helpers.ts create mode 100644 packages/hdwallet-ledger/src/thorchain/hw-app-thor.ts create mode 100644 packages/hdwallet-ledger/src/thorchain/index.ts diff --git a/packages/hdwallet-ledger-webusb/src/transport.ts b/packages/hdwallet-ledger-webusb/src/transport.ts index 52623993a..d41e8cb34 100644 --- a/packages/hdwallet-ledger-webusb/src/transport.ts +++ b/packages/hdwallet-ledger-webusb/src/transport.ts @@ -79,6 +79,13 @@ export async function translateCoinAndMethod> { switch (coin) { + case "Rune": { + const thor = new ledger.THORChainApp({ transport }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO(gomes): fixme + const methodInstance = thor[method as LedgerTransportMethodName<"Rune">].bind(thor); + return methodInstance as LedgerTransportMethod; + } case "Btc": { const btc = new Btc({ transport }); const methodInstance = btc[method as LedgerTransportMethodName<"Btc">].bind(btc); diff --git a/packages/hdwallet-ledger/package.json b/packages/hdwallet-ledger/package.json index 7f4af4e46..7a569bffc 100644 --- a/packages/hdwallet-ledger/package.json +++ b/packages/hdwallet-ledger/package.json @@ -25,7 +25,9 @@ "bs58check": "2.1.2", "ethereumjs-tx": "1.3.7", "ethereumjs-util": "^6.1.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "ripemd160": "^2.0.2", + "@types/ripemd160": "^2.0.1" }, "devDependencies": { "@ledgerhq/hw-app-btc": "^10.0.8", diff --git a/packages/hdwallet-ledger/src/index.ts b/packages/hdwallet-ledger/src/index.ts index c73f59c8c..063c34f8e 100644 --- a/packages/hdwallet-ledger/src/index.ts +++ b/packages/hdwallet-ledger/src/index.ts @@ -1,5 +1,6 @@ export * from "./bitcoin"; export * from "./ethereum"; +export * from "./thorchain"; export * from "./ledger"; export * from "./transport"; export * from "./utils"; diff --git a/packages/hdwallet-ledger/src/ledger.ts b/packages/hdwallet-ledger/src/ledger.ts index 5a79bd54c..de357b002 100644 --- a/packages/hdwallet-ledger/src/ledger.ts +++ b/packages/hdwallet-ledger/src/ledger.ts @@ -3,6 +3,7 @@ import _ from "lodash"; import * as btc from "./bitcoin"; import * as eth from "./ethereum"; +import * as thorchain from "./thorchain"; import { LedgerTransport } from "./transport"; import { coinToLedgerAppName, handleError } from "./utils"; @@ -302,6 +303,8 @@ export class LedgerHDWallet implements core.HDWallet, core.BTCWallet, core.ETHWa readonly _supportsPolygon = true; readonly _supportsGnosis = true; readonly _supportsArbitrum = true; + readonly _supportsThorchainInfo = true; + readonly _supportsThorchain = true; _isLedger = true; @@ -509,6 +512,9 @@ export class LedgerHDWallet implements core.HDWallet, core.BTCWallet, core.ETHWa await this.validateCurrentApp(msg.coin); return btc.btcGetAddress(this.transport, msg); } + public thorchainGetAddress(msg: core.ThorchainGetAddress): Promise { + return thorchain.thorchainGetAddress(this.transport, msg); + } public async btcSignTx(msg: core.BTCSignTxLedger): Promise { await this.validateCurrentApp(msg.coin); diff --git a/packages/hdwallet-ledger/src/thorchain/common.ts b/packages/hdwallet-ledger/src/thorchain/common.ts new file mode 100644 index 000000000..0f6cb4f5a --- /dev/null +++ b/packages/hdwallet-ledger/src/thorchain/common.ts @@ -0,0 +1,113 @@ +export enum ErrorCode { + NoError = 0x9000, +} + +export const CLA = 0x55; +export const CHUNK_SIZE = 250; +export const APP_KEY = "CSM"; + +export const INS = { + GET_VERSION: 0x00, + INS_PUBLIC_KEY_SECP256K1: 0x01, // Obsolete + SIGN_SECP256K1: 0x02, + GET_ADDR_SECP256K1: 0x04, +}; + +export const PAYLOAD_TYPE = { + INIT: 0x00, + ADD: 0x01, + LAST: 0x02, +}; + +export const P1_VALUES = { + ONLY_RETRIEVE: 0x00, + SHOW_ADDRESS_IN_DEVICE: 0x01, +}; + +const ERROR_DESCRIPTION = { + 1: "U2F: Unknown", + 2: "U2F: Bad request", + 3: "U2F: Configuration unsupported", + 4: "U2F: Device Ineligible", + 5: "U2F: Timeout", + 14: "Timeout", + 0x9000: "No errors", + 0x9001: "Device is busy", + 0x6802: "Error deriving keys", + 0x6400: "Execution Error", + 0x6700: "Wrong Length", + 0x6982: "Empty Buffer", + 0x6983: "Output buffer too small", + 0x6984: "Data is invalid", + 0x6985: "Conditions not satisfied", + 0x6986: "Transaction rejected", + 0x6a80: "Bad key handle", + 0x6b00: "Invalid P1/P2", + 0x6d00: "Instruction not supported", + 0x6e00: "App does not seem to be open", + 0x6f00: "Unknown error", + 0x6f01: "Sign/verify error", +}; + +export function errorCodeToString(statusCode: any) { + if (statusCode in ERROR_DESCRIPTION) return ERROR_DESCRIPTION[statusCode as 1]; + return `Unknown Status Code: ${statusCode}`; +} + +export function processErrorResponse(response: any) { + if (response) { + if ( + typeof response === "object" && + response !== null && + !(response instanceof Array) && + !(response instanceof Date) + ) { + if (Object.prototype.hasOwnProperty.call(response, "statusCode")) { + return { + return_code: response.statusCode, + error_message: errorCodeToString(response.statusCode), + }; + } + + if ( + Object.prototype.hasOwnProperty.call(response, "return_code") && + Object.prototype.hasOwnProperty.call(response, "error_message") + ) { + return response; + } + } + return { + return_code: 0xffff, + error_message: response.toString(), + }; + } + + return { + return_code: 0xffff, + error_message: response.toString(), + }; +} + +export async function getVersion(transport: any) { + return transport.send(CLA, INS.GET_VERSION, 0, 0).then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + + let targetId = 0; + if (response.length >= 9) { + targetId = (response[5] << 24) + (response[6] << 16) + (response[7] << 8) + (response[8] << 0); + } + + return { + return_code: returnCode, + error_message: errorCodeToString(returnCode), + // /// + test_mode: response[0] !== 0, + major: response[1], + minor: response[2], + patch: response[3], + device_locked: response[4] === 1, + target_id: targetId.toString(16), + }; + }, processErrorResponse); +} diff --git a/packages/hdwallet-ledger/src/thorchain/helpers.ts b/packages/hdwallet-ledger/src/thorchain/helpers.ts new file mode 100644 index 000000000..81dca3828 --- /dev/null +++ b/packages/hdwallet-ledger/src/thorchain/helpers.ts @@ -0,0 +1,68 @@ +import { CLA, ErrorCode, errorCodeToString, INS, PAYLOAD_TYPE, processErrorResponse } from "./common"; + +const signSendChunkv1 = async (app: any, chunkIdx: any, chunkNum: any, chunk: any) => { + return app.transport + .send(CLA, INS.SIGN_SECP256K1, chunkIdx, chunkNum, chunk, [ErrorCode.NoError, 0x6984, 0x6a80]) + .then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + let errorMessage = errorCodeToString(returnCode); + + if (returnCode === 0x6a80 || returnCode === 0x6984) { + errorMessage = `${errorMessage} : ${response.slice(0, response.length - 2).toString("ascii")}`; + } + + let signature = null; + if (response.length > 2) { + signature = response.slice(0, response.length - 2); + } + + return { + signature, + return_code: returnCode, + error_message: errorMessage, + }; + }, processErrorResponse); +}; + +export const serializePathv2 = (path: any) => { + if (!path || path.length !== 5) { + throw new Error("Invalid path."); + } + + const buf = Buffer.alloc(20); + buf.writeUInt32LE(0x80000000 + path[0], 0); + buf.writeUInt32LE(0x80000000 + path[1], 4); + buf.writeUInt32LE(0x80000000 + path[2], 8); + buf.writeUInt32LE(path[3], 12); + buf.writeUInt32LE(path[4], 16); + + return buf; +}; + +export const signSendChunkv2 = async (app: any, chunkIdx: any, chunkNum: any, chunk: any) => { + let payloadType = PAYLOAD_TYPE.ADD; + if (chunkIdx === 1) { + payloadType = PAYLOAD_TYPE.INIT; + } + if (chunkIdx === chunkNum) { + payloadType = PAYLOAD_TYPE.LAST; + } + + return signSendChunkv1(app, payloadType, 0, chunk); +}; + +export const publicKeyv2 = async (app: any, data: any) => { + return app.transport.send(CLA, INS.GET_ADDR_SECP256K1, 0, 0, data, [ErrorCode.NoError]).then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + const compressedPk = Buffer.from(response.slice(0, 33)); + + return { + pk: "OBSOLETE PROPERTY", + compressed_pk: compressedPk, + return_code: returnCode, + error_message: errorCodeToString(returnCode), + }; + }, processErrorResponse); +}; diff --git a/packages/hdwallet-ledger/src/thorchain/hw-app-thor.ts b/packages/hdwallet-ledger/src/thorchain/hw-app-thor.ts new file mode 100644 index 000000000..18b20982b --- /dev/null +++ b/packages/hdwallet-ledger/src/thorchain/hw-app-thor.ts @@ -0,0 +1,331 @@ +/** ****************************************************************************** + * (c) 2019 ZondaX GmbH + * (c) 2016-2017 Ledger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ******************************************************************************* */ +import { bech32 } from "@scure/base"; +import crypto from "crypto"; +import Ripemd160 from "ripemd160"; + +import { + APP_KEY, + CHUNK_SIZE, + CLA, + ErrorCode, + errorCodeToString, + getVersion, + INS, + P1_VALUES, + processErrorResponse, +} from "./common"; +import { publicKeyv2, serializePathv2, signSendChunkv2 } from "./helpers"; + +class THORChainApp { + transport: any; + versionResponse: any; + + constructor(transport: any, scrambleKey = APP_KEY) { + if (!transport) { + throw new Error("Transport has not been defined"); + } + + this.transport = transport as any; + transport.decorateAppAPIMethods( + this, + ["getVersion", "sign", "getAddressAndPubKey", "appInfo", "deviceInfo", "getBech32FromPK"], + scrambleKey + ); + } + + static serializeHRP(hrp: any) { + if (hrp == null || hrp.length < 3 || hrp.length > 83) { + throw new Error("Invalid HRP"); + } + const buf = Buffer.alloc(1 + hrp.length); + buf.writeUInt8(hrp.length, 0); + buf.write(hrp, 1); + return buf; + } + + static getBech32FromPK(hrp: any, pk: any) { + if (pk.length !== 33) { + throw new Error("expected compressed public key [31 bytes]"); + } + const hashSha256 = crypto.createHash("sha256").update(pk).digest(); + const hashRip = new Ripemd160().update(hashSha256).digest(); + // ts is drunk and doesn't like bech32.bech32 here + const encode = bech32.encode || (bech32 as any).bech32?.encode; + // ts is drunk and doesn't like bech32.bech32 here + const toWords = bech32.toWords || (bech32 as any).bech32?.toWords; + + return encode(hrp, toWords(hashRip)); + } + + async serializePath(path: string) { + this.versionResponse = await getVersion(this.transport); + + if (this.versionResponse.return_code !== ErrorCode.NoError) { + throw this.versionResponse; + } + + switch (this.versionResponse.major) { + case 2: + return serializePathv2(path); + default: + return { + return_code: 0x6400, + error_message: "App Version is not supported", + }; + } + } + + async signGetChunks(path: string, message: any) { + const serializedPath = await this.serializePath(path); + + const chunks = []; + chunks.push(serializedPath); + const buffer = Buffer.from(message); + + for (let i = 0; i < buffer.length; i += CHUNK_SIZE) { + let end = i + CHUNK_SIZE; + if (i > buffer.length) { + end = buffer.length; + } + chunks.push(buffer.slice(i, end)); + } + + return chunks; + } + + async getVersion() { + try { + this.versionResponse = await getVersion(this.transport); + return this.versionResponse; + } catch (e) { + return processErrorResponse(e); + } + } + + async appInfo() { + return this.transport.send(0xb0, 0x01, 0, 0).then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + + const result = {} as any; + + let appName = "err"; + let appVersion = "err"; + let flagLen = 0; + let flagsValue = 0; + + if (response[0] !== 1) { + // Ledger responds with format ID 1. There is no spec for any format != 1 + result.error_message = "response format ID not recognized"; + result.return_code = 0x9001; + } else { + const appNameLen = response[1]; + appName = response.slice(2, 2 + appNameLen).toString("ascii"); + let idx = 2 + appNameLen; + const appVersionLen = response[idx]; + idx += 1; + appVersion = response.slice(idx, idx + appVersionLen).toString("ascii"); + idx += appVersionLen; + const appFlagsLen = response[idx]; + idx += 1; + flagLen = appFlagsLen; + flagsValue = response[idx]; + } + + return { + return_code: returnCode, + error_message: errorCodeToString(returnCode), + // // + appName, + appVersion, + flagLen, + flagsValue, + + flag_recovery: (flagsValue & 1) !== 0, + + flag_signed_mcu_code: (flagsValue & 2) !== 0, + + flag_onboarded: (flagsValue & 4) !== 0, + + flag_pin_validated: (flagsValue & 128) !== 0, + }; + }, processErrorResponse); + } + + async deviceInfo() { + return this.transport.send(0xe0, 0x01, 0, 0, Buffer.from([]), [ErrorCode.NoError, 0x6e00]).then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + + if (returnCode === 0x6e00) { + return { + return_code: returnCode, + error_message: "This command is only available in the Dashboard", + }; + } + + const targetId = response.slice(0, 4).toString("hex"); + + let pos = 4; + const secureElementVersionLen = response[pos]; + pos += 1; + const seVersion = response.slice(pos, pos + secureElementVersionLen).toString(); + pos += secureElementVersionLen; + + const flagsLen = response[pos]; + pos += 1; + const flag = response.slice(pos, pos + flagsLen).toString("hex"); + pos += flagsLen; + + const mcuVersionLen = response[pos]; + pos += 1; + // Patch issue in mcu version + let tmp = response.slice(pos, pos + mcuVersionLen); + if (tmp[mcuVersionLen - 1] === 0) { + tmp = response.slice(pos, pos + mcuVersionLen - 1); + } + const mcuVersion = tmp.toString(); + + return { + return_code: returnCode, + error_message: errorCodeToString(returnCode), + // // + targetId, + seVersion, + flag, + mcuVersion, + }; + }, processErrorResponse); + } + + async publicKey(path: string) { + try { + const serializedPath = await this.serializePath(path); + + switch (this.versionResponse.major) { + case 2: { + const data = Buffer.concat([THORChainApp.serializeHRP("thor"), serializedPath as any]); + return await publicKeyv2(this, data); + } + default: + return { + return_code: 0x6400, + error_message: "App Version is not supported", + }; + } + } catch (e) { + return processErrorResponse(e); + } + } + + async getAddressAndPubKey(path: string, hrp: any) { + try { + return await this.serializePath(path) + .then((serializedPath) => { + const data = Buffer.concat([THORChainApp.serializeHRP(hrp), serializedPath as any]); + return this.transport + .send(CLA, INS.GET_ADDR_SECP256K1, P1_VALUES.ONLY_RETRIEVE, 0, data, [ErrorCode.NoError]) + .then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + + const compressedPk = Buffer.from(response.slice(0, 33)); + const bech32Address = Buffer.from(response.slice(33, -2)).toString(); + + return { + bech32_address: bech32Address, + compressed_pk: compressedPk, + return_code: returnCode, + error_message: errorCodeToString(returnCode), + }; + }, processErrorResponse); + }) + .catch((err) => processErrorResponse(err)); + } catch (e) { + return processErrorResponse(e); + } + } + + async showAddressAndPubKey(path: string, hrp: any) { + try { + return await this.serializePath(path) + .then((serializedPath) => { + const data = Buffer.concat([THORChainApp.serializeHRP(hrp), serializedPath as any]); + return this.transport + .send(CLA, INS.GET_ADDR_SECP256K1, P1_VALUES.SHOW_ADDRESS_IN_DEVICE, 0, data, [ErrorCode.NoError]) + .then((response: any) => { + const errorCodeData = response.slice(-2); + const returnCode = errorCodeData[0] * 256 + errorCodeData[1]; + + const compressedPk = Buffer.from(response.slice(0, 33)); + const bech32Address = Buffer.from(response.slice(33, -2)).toString(); + + return { + bech32_address: bech32Address, + compressed_pk: compressedPk, + return_code: returnCode, + error_message: errorCodeToString(returnCode), + }; + }, processErrorResponse); + }) + .catch((err) => processErrorResponse(err)); + } catch (e) { + return processErrorResponse(e); + } + } + + async signSendChunk(chunkIdx: number, chunkNum: number, chunk: any) { + switch (this.versionResponse.major) { + case 2: + return signSendChunkv2(this, chunkIdx, chunkNum, chunk); + default: + return { + return_code: 0x6400, + error_message: "App Version is not supported", + }; + } + } + + async sign(path: string, message: any) { + return this.signGetChunks(path, message).then((chunks) => { + return this.signSendChunk(1, chunks.length, chunks[0]).then(async (response) => { + let result = { + return_code: response.return_code, + error_message: response.error_message, + signature: null, + }; + + for (let i = 1; i < chunks.length; i += 1) { + result = await this.signSendChunk(1 + i, chunks.length, chunks[i]); + if (result.return_code !== ErrorCode.NoError) { + break; + } + } + + return { + return_code: result.return_code, + error_message: result.error_message, + // /// + signature: result.signature, + }; + }, processErrorResponse); + }, processErrorResponse); + } +} + +export { THORChainApp }; diff --git a/packages/hdwallet-ledger/src/thorchain/index.ts b/packages/hdwallet-ledger/src/thorchain/index.ts new file mode 100644 index 000000000..87e9941e4 --- /dev/null +++ b/packages/hdwallet-ledger/src/thorchain/index.ts @@ -0,0 +1,26 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; + +import { LedgerTransport } from ".."; +export * from "./common"; +export * from "./helpers"; +export * from "./hw-app-thor"; + +// TODO(gomes): move all below to ./thorchain + +export const thorchainGetAddress = async ( + transport: LedgerTransport, + msg: core.ThorchainGetAddress +): Promise => { + const addressAndPubkey = await transport.call( + "Rune", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO(gomes): fixme + "getAddressAndPubKey", + core.addressNListToBIP32(msg.addressNList), + "thor" + ); + + // eslint-disable-next-line no-console + console.log({ addressAndPubkey }); + return "TODO"; +}; diff --git a/packages/hdwallet-ledger/src/transport.ts b/packages/hdwallet-ledger/src/transport.ts index 70178159d..fe6889ba7 100644 --- a/packages/hdwallet-ledger/src/transport.ts +++ b/packages/hdwallet-ledger/src/transport.ts @@ -12,7 +12,7 @@ type MethodsOnly = { type UnwrapPromise = T extends Promise ? R : T; type DefinitelyCallable = T extends (...args: any) => any ? T : never; -export type LedgerTransportCoinType = null | "Btc" | "Eth"; +export type LedgerTransportCoinType = null | "Btc" | "Eth" | "Rune"; type CurriedWithTransport any> = T extends ( transport: Transport, ...args: infer R diff --git a/yarn.lock b/yarn.lock index 2dcfd0cfa..f29842390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6431,6 +6431,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/ripemd160@^2.0.1": + version "2.0.1" + resolved "http://localhost:4873/@types/ripemd160/-/ripemd160-2.0.1.tgz#e9ea7a26adf0e2541917fc2b1ecb497e8ced4b7e" + integrity sha512-l3pt9f8vK1tU2qBXY1aL7HofNnJKbpNa+2DznrD7j0lhaS4587e3iwgSMRDJFlidsrNjrrjIl8G+EeXFMjcGXg== + dependencies: + "@types/node" "*" + "@types/secp256k1@^4.0.1": version "4.0.3" resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.3.tgz#1b8e55d8e00f08ee7220b4d59a6abe89c37a901c"