UNPKG

@iota/ledgerjs-hw-app-iota

Version:
323 lines (288 loc) 10.8 kB
// Copyright (c) Mysten Labs, Inc. // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 /* eslint-disable no-restricted-globals */ import type Transport from '@ledgerhq/hw-transport'; import sha256 from 'fast-sha256'; export type GetPublicKeyResult = { publicKey: Uint8Array; address: Uint8Array; }; export type SignTransactionResult = { signature: Uint8Array; }; export type GetVersionResult = { major: number; minor: number; patch: number; }; enum LedgerToHost { RESULT_ACCUMULATING = 0, RESULT_FINAL = 1, GET_CHUNK = 2, PUT_CHUNK = 3, } enum HostToLedger { START = 0, GET_CHUNK_RESPONSE_SUCCESS = 1, GET_CHUNK_RESPONSE_FAILURE = 2, PUT_CHUNK_RESPONSE = 3, RESULT_ACCUMULATING_RESPONSE = 4, } /** * Iota API * * @example * import Iota from "@iota/ledgerjs-hw-app-iota"; * const iota = new Iota(transport) */ export default class Iota { transport: Transport; readonly #verbose: boolean; constructor(transport: Transport, scrambleKey = 'default_iota_scramble_key', verbose = false) { this.#verbose = verbose; this.transport = transport; this.transport.decorateAppAPIMethods( this, ['getPublicKey', 'signTransaction', 'getVersion'], scrambleKey, ); } /** * Retrieves the public key associated with a particular BIP32 path from the Ledger app. * * @param path - the path to retrieve. * @param displayOnDevice - whether or not the address should be displayed on the device. * */ async getPublicKey(path: string, displayOnDevice = false): Promise<GetPublicKeyResult> { const cla = 0x00; const ins = displayOnDevice ? 0x01 : 0x02; const p1 = 0; const p2 = 0; const payload = buildBip32KeyPayload(path); const response = await this.#sendChunks(cla, ins, p1, p2, payload); const keySize = response[0]; const publicKey = response.slice(1, keySize + 1); // slice uses end index. let address: Uint8Array | null = null; if (response.length > keySize + 2) { const addressSize = response[keySize + 1]; address = response.slice(keySize + 2, keySize + 2 + addressSize) as Uint8Array; } const res: GetPublicKeyResult = { publicKey: publicKey as Uint8Array, address: address!, }; return res; } /** * Sign a transaction with the key at a BIP32 path. * * @param txn - The transaction bytes to sign. * @param path - The path to use when signing the transaction. * @param options - Additional options used for clear signing purposes. */ async signTransaction( path: string, txn: Uint8Array, options?: { bcsObjects: Uint8Array[]; }, ): Promise<SignTransactionResult> { const cla = 0x00; const ins = 0x03; const p1 = 0; const p2 = 0; if (this.#verbose) this.#log(txn); // Transaction payload is the byte length as uint32le followed by the bytes const rawTxn = Buffer.from(txn); const hashSize = Buffer.alloc(4); hashSize.writeUInt32LE(rawTxn.length, 0); // Build transaction payload: const payloadTxn = Buffer.concat([hashSize, rawTxn] as Uint8Array[]); this.#log('Payload Txn', payloadTxn); const bip32KeyPayload = buildBip32KeyPayload(path); const payloads = [payloadTxn, bip32KeyPayload]; // The public getVersion is decorated with a lock in the constructor: const { major } = await this.#internalGetVersion(); const bcsObjects = options?.bcsObjects ?? []; this.#log('Objects list length', bcsObjects.length); this.#log('App version', major); if (major > 0 && bcsObjects.length > 0) { // Build object list payload: const numItems = Buffer.alloc(4); numItems.writeUInt32LE(bcsObjects.length, 0); let listData = Buffer.from(numItems as Uint8Array); // Add each item with its length prefix: for (const item of bcsObjects) { const rawItem = Buffer.from(item); const itemLen = Buffer.alloc(4); itemLen.writeUInt32LE(rawItem.length, 0); listData = Buffer.concat([listData, itemLen, rawItem] as Uint8Array[]); } payloads.push(listData); } // Send the chunks and return the signature const signature = await this.#sendChunks(cla, ins, p1, p2, payloads); return { signature: signature as Uint8Array }; } /** * Retrieve the app version on the attached Ledger device. */ async getVersion(): Promise<GetVersionResult> { return await this.#internalGetVersion(); } async #internalGetVersion() { const [major, minor, patch] = await this.#sendChunks( 0x00, 0x00, 0x00, 0x00, Buffer.alloc(1), ); return { major, minor, patch, }; } /** * Convert a raw payload into what is essentially a singly-linked list of chunks, which * allows the ledger to re-seek the data in a secure fashion. */ async #sendChunks( cla: number, ins: number, p1: number, p2: number, payload: Buffer | Buffer[], // Constant (protocol dependent) data that the ledger may want to refer to // besides the payload. extraData: Map<String, Buffer> = new Map<String, Buffer>(), ): Promise<Buffer> { const chunkSize = 180; if (!(payload instanceof Array)) { payload = [payload]; } const parameterList: Buffer[] = []; let data = new Map<String, Buffer>(extraData); for (let j = 0; j < payload.length; j++) { const chunkList: Buffer[] = []; for (let i = 0; i < payload[j].length; i += chunkSize) { const cur = payload[j].slice(i, i + chunkSize); chunkList.push(cur); } // Store the hash that points to the "rest of the list of chunks" let lastHash = Buffer.alloc(32); this.#log(lastHash); // Since we are doing a foldr, we process the last chunk first // We have to do it this way, because a block knows the hash of // the next block. data = chunkList.reduceRight((blocks, chunk) => { const linkedChunk = Buffer.concat([lastHash, chunk] as Uint8Array[]); this.#log('Chunk: ', chunk); this.#log('linkedChunk: ', linkedChunk); lastHash = Buffer.from(sha256(linkedChunk as Uint8Array)); blocks.set(lastHash.toString('hex'), linkedChunk); return blocks; }, data); parameterList.push(lastHash); lastHash = Buffer.alloc(32); } this.#log(data); return await this.#handleBlocksProtocol( cla, ins, p1, p2, Buffer.concat( ([Buffer.from([HostToLedger.START])] as Uint8Array[]).concat( parameterList as Uint8Array[], ), ), data, ); } async #handleBlocksProtocol( cla: number, ins: number, p1: number, p2: number, initialPayload: Buffer, data: Map<String, Buffer>, ): Promise<Buffer> { let payload = initialPayload; let result = Buffer.alloc(0); let rv_instruction; do { this.#log('Sending payload to ledger: ', payload.toString('hex')); const rv = await this.transport.send(cla, ins, p1, p2, payload); this.#log('Received response: ', rv); rv_instruction = rv[0]; const rv_payload = rv.slice(1, rv.length - 2); // Last two bytes are a return code. if (!(rv_instruction in LedgerToHost)) { throw new TypeError('Unknown instruction returned from ledger'); } switch (rv_instruction) { case LedgerToHost.RESULT_ACCUMULATING: case LedgerToHost.RESULT_FINAL: result = Buffer.concat([result, rv_payload] as Uint8Array[]); // Won't actually send this if we drop out of the loop for RESULT_FINAL payload = Buffer.from([HostToLedger.RESULT_ACCUMULATING_RESPONSE]); break; case LedgerToHost.GET_CHUNK: const chunk = data.get(rv_payload.toString('hex')); this.#log('Getting block ', rv_payload); this.#log('Found block ', chunk); if (chunk) { payload = Buffer.concat([ Buffer.from([HostToLedger.GET_CHUNK_RESPONSE_SUCCESS]), chunk, ] as Uint8Array[]); } else { payload = Buffer.from([HostToLedger.GET_CHUNK_RESPONSE_FAILURE]); } break; case LedgerToHost.PUT_CHUNK: data.set( Buffer.from(sha256(rv_payload as Uint8Array)).toString('hex'), rv_payload, ); payload = Buffer.from([HostToLedger.PUT_CHUNK_RESPONSE]); break; } } while (rv_instruction !== LedgerToHost.RESULT_FINAL); return result; } #log(...args: any[]) { if (this.#verbose) console.log(args); } } function buildBip32KeyPayload(path: string): Buffer { const paths = splitPath(path); // Bip32Key payload is: // 1 byte with number of elements in u32 array path // Followed by the u32 array itself const payload = Buffer.alloc(1 + paths.length * 4); payload[0] = paths.length; paths.forEach((element, index) => { payload.writeUInt32LE(element, 1 + 4 * index); }); return payload; } // TODO use bip32-path library function splitPath(path: string): number[] { const result: number[] = []; const components = path.split('/'); components.forEach((element) => { let number = parseInt(element, 10); if (isNaN(number)) { return; // FIXME shouldn't it throws instead? } if (element.length > 1 && element[element.length - 1] === "'") { number += 0x80000000; } result.push(number); }); return result; }