From d6e97c7e2d092b5735d6aee916c6c6c047818fcd Mon Sep 17 00:00:00 2001 From: GUO YANKE Date: Mon, 1 Jul 2024 17:57:34 +0800 Subject: [PATCH] feat: complete full trezor interaction support --- examples/hardhat.config.ts | 4 +- package-lock.json | 23 +- package.json | 7 +- proto/messages-management.proto | 651 ++++++++++++++++++++++++++++++++ src/trezor-client.ts | 189 +++++++--- src/trezor-provider.ts | 45 +-- src/trezor-wire.ts | 114 ++++-- 7 files changed, 913 insertions(+), 120 deletions(-) create mode 100644 proto/messages-management.proto diff --git a/examples/hardhat.config.ts b/examples/hardhat.config.ts index c50c52f..45a116a 100644 --- a/examples/hardhat.config.ts +++ b/examples/hardhat.config.ts @@ -1,3 +1,4 @@ +import "@nomicfoundation/hardhat-ethers"; import "../dist/index"; import { HardhatUserConfig } from "hardhat/config"; import { task } from "hardhat/config"; @@ -14,7 +15,8 @@ task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { module.exports = { solidity: "0.8.24", networks: { - hardhat: { + sepolia: { + url: "https://sepolia.infura.io/v3/" + process.env.INFURA_API_KEY, trezorAccountIndexes: [[44, 60, 0, 0, 0]], }, }, diff --git a/package-lock.json b/package-lock.json index 6ea314b..ba83cb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "protobufjs": "^7.3.2" }, "devDependencies": { + "@nomicfoundation/hardhat-ethers": "^3.0.6", "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@types/node": "^20.14.9", + "ethers": "^6.13.1", "hardhat": "^2.22.5", "prettier": "3.3.2", "protobufjs-cli": "^1.1.2", @@ -26,8 +28,7 @@ "version": "1.10.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@babel/parser": { "version": "7.24.7", @@ -1058,7 +1059,6 @@ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "dev": true, - "peer": true, "dependencies": { "@noble/hashes": "1.3.2" }, @@ -1071,7 +1071,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "dev": true, - "peer": true, "engines": { "node": ">= 16" }, @@ -1354,7 +1353,6 @@ "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ethers/-/hardhat-ethers-3.0.6.tgz", "integrity": "sha512-/xzkFQAaHQhmIAYOQmvHBPwL+NkwLzT9gRZBsgWUYeV+E6pzXsBQsHfRYbAZ3XEYare+T7S+5Tg/1KDJgepSkA==", "dev": true, - "peer": true, "dependencies": { "debug": "^4.1.1", "lodash.isequal": "^4.5.0" @@ -2308,8 +2306,7 @@ "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/agent-base": { "version": "6.0.2", @@ -3819,7 +3816,6 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "peer": true, "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -3838,7 +3834,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "dev": true, - "peer": true, "engines": { "node": ">= 16" }, @@ -3850,22 +3845,19 @@ "version": "18.15.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/ethers/node_modules/tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/ethers/node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -4995,8 +4987,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/lodash.truncate": { "version": "4.4.2", diff --git a/package.json b/package.json index 0c46af5..36c2c89 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,8 @@ "CHANGELOG.md" ], "scripts": { - "prettier": "prettier . --write", - "protoc": "cd proto && protoc --plugin=../node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=../src/proto *.proto && npm run prettier", - "build": "npm run prettier && rimraf dist tsconfig.tsbuildinfo && tsc" + "format": "prettier . --write", + "build": "npm run format && rimraf dist tsconfig.tsbuildinfo && tsc" }, "repository": { "type": "git", @@ -29,8 +28,10 @@ }, "homepage": "https://github.com/yankeguo/hardhat-trezor#readme", "devDependencies": { + "@nomicfoundation/hardhat-ethers": "^3.0.6", "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@types/node": "^20.14.9", + "ethers": "^6.13.1", "hardhat": "^2.22.5", "prettier": "3.3.2", "protobufjs-cli": "^1.1.2", diff --git a/proto/messages-management.proto b/proto/messages-management.proto new file mode 100644 index 0000000..8c119ae --- /dev/null +++ b/proto/messages-management.proto @@ -0,0 +1,651 @@ +syntax = "proto2"; +package hw.trezor.messages.management; + +// Sugar for easier handling in Java +option java_package = "com.satoshilabs.trezor.lib.protobuf"; +option java_outer_classname = "TrezorMessageManagement"; + +option (include_in_bitcoin_only) = true; + +import "messages.proto"; + +/** + * Type of the mnemonic backup given/received by the device during reset/recovery. + */ +enum BackupType { + Bip39 = 0; // also called "Single Backup", see BIP-0039 + Slip39_Basic = 1; // also called "Shamir Backup", see SLIP-0039 + Slip39_Advanced = 2; // also called "Super Shamir" or "Shamir with Groups", see SLIP-0039#two-level-scheme + Slip39_Single_Extendable = 3; // extendable single-share Shamir backup + Slip39_Basic_Extendable = 4; // extendable multi-share Shamir backup + Slip39_Advanced_Extendable = 5; // extendable multi-share Shamir backup with groups +} + +/** + * Level of safety checks for unsafe actions like spending from invalid path namespace or setting high transaction fee. + */ +enum SafetyCheckLevel { + Strict = 0; // disallow unsafe actions, this is the default + PromptAlways = 1; // ask user before unsafe action + PromptTemporarily = 2; // like PromptAlways but reverts to Strict after reboot +} + + +/** + * Format of the homescreen image + */ +enum HomescreenFormat { + Toif = 1; // full-color toif + Jpeg = 2; // jpeg + ToiG = 3; // greyscale toif +} + +/** + * Request: Reset device to default state and ask for device details + * @start + * @next Features + */ +message Initialize { + optional bytes session_id = 1; // assumed device session id; Trezor clears caches if it is different or empty + optional bool _skip_passphrase = 2 [deprecated=true]; // removed as part of passphrase redesign + optional bool derive_cardano = 3; // whether to derive Cardano Icarus root keys in this session +} + +/** + * Request: Ask for device details (no device reset) + * @start + * @next Features + */ +message GetFeatures { +} + +/** + * Response: Reports various information about the device + * @end + */ +message Features { + optional string vendor = 1; // name of the manufacturer, e.g. "trezor.io" + required uint32 major_version = 2; // major version of the firmware/bootloader, e.g. 1 + required uint32 minor_version = 3; // minor version of the firmware/bootloader, e.g. 0 + required uint32 patch_version = 4; // patch version of the firmware/bootloader, e.g. 0 + optional bool bootloader_mode = 5; // is device in bootloader mode? + optional string device_id = 6; // device's unique identifier + optional bool pin_protection = 7; // is device protected by PIN? + optional bool passphrase_protection = 8; // is node/mnemonic encrypted using passphrase? + optional string language = 9; // device language + optional string label = 10; // device description label + optional bool initialized = 12; // does device contain seed? + optional bytes revision = 13; // SCM revision of firmware + optional bytes bootloader_hash = 14; // hash of the bootloader + optional bool imported = 15; // was storage imported from an external source? + optional bool unlocked = 16; // is the device unlocked? called "pin_cached" previously + optional bool _passphrase_cached = 17 [deprecated=true]; // is passphrase already cached in session? + optional bool firmware_present = 18; // is valid firmware loaded? + optional BackupAvailability backup_availability = 19; // does storage need backup? is repeated backup unlocked? + optional uint32 flags = 20; // device flags (equals to Storage.flags) + optional string model = 21; // device hardware model + optional uint32 fw_major = 22; // reported firmware version if in bootloader mode + optional uint32 fw_minor = 23; // reported firmware version if in bootloader mode + optional uint32 fw_patch = 24; // reported firmware version if in bootloader mode + optional string fw_vendor = 25; // reported firmware vendor if in bootloader mode + // optional bytes fw_vendor_keys = 26; // obsoleted, use fw_vendor + optional bool unfinished_backup = 27; // report unfinished backup (equals to Storage.unfinished_backup) + optional bool no_backup = 28; // report no backup (equals to Storage.no_backup) + optional RecoveryStatus recovery_status = 29; // whether or not we are in recovery mode and of what kind + repeated Capability capabilities = 30; // list of supported capabilities + optional BackupType backup_type = 31; // type of device backup (BIP-39 / SLIP-39 basic / SLIP-39 advanced) + optional bool sd_card_present = 32; // is SD card present + optional bool sd_protection = 33; // is SD Protect enabled + optional bool wipe_code_protection = 34; // is wipe code protection enabled + optional bytes session_id = 35; + optional bool passphrase_always_on_device = 36; // device enforces passphrase entry on Trezor + optional SafetyCheckLevel safety_checks = 37; // safety check level, set to Prompt to limit path namespace enforcement + optional uint32 auto_lock_delay_ms = 38; // number of milliseconds after which the device locks itself + optional uint32 display_rotation = 39; // in degrees from North + optional bool experimental_features = 40; // are experimental message types enabled? + optional bool busy = 41; // is the device busy, showing "Do not disconnect"? + optional HomescreenFormat homescreen_format = 42; // format of the homescreen, 1 = TOIf, 2 = jpg, 3 = TOIG + optional bool hide_passphrase_from_host = 43; // should we hide the passphrase when it comes from host? + optional string internal_model = 44; // internal model name + optional uint32 unit_color = 45; // color of the unit/device + optional bool unit_btconly = 46; // unit/device is intended as bitcoin only + optional uint32 homescreen_width = 47; // homescreen width in pixels + optional uint32 homescreen_height = 48; // homescreen height in pixels + optional bool bootloader_locked = 49; // bootloader is locked + optional bool language_version_matches = 50 [default=true]; // translation blob version matches firmware version + optional uint32 unit_packaging = 51; // unit/device packaging version + optional bool haptic_feedback = 52; // haptic feedback is enabled + optional RecoveryType recovery_type = 53; // what type of recovery we are in. NB: this works in conjunction with recovery_status + optional uint32 optiga_sec = 54; // Optiga's security event counter. + + enum BackupAvailability { + /// Device is already backed up, or a previous backup has failed. + NotAvailable = 0; + /// Device is not backed up. Backup is required. + Required = 1; + /// Device is already backed up and can be backed up again. + Available = 2; + } + + enum RecoveryStatus { + Nothing = 0; // we are not in recovery mode + Recovery = 1; // we are in "Normal" or "DryRun" recovery + Backup = 2; // we are in repeated backup mode + } + + enum Capability { + option (has_bitcoin_only_values) = true; + + Capability_Bitcoin = 1 [(bitcoin_only) = true]; + Capability_Bitcoin_like = 2; // Altcoins based on the Bitcoin source code + Capability_Binance = 3; + Capability_Cardano = 4; + Capability_Crypto = 5 [(bitcoin_only) = true]; // generic crypto operations for GPG, SSH, etc. + Capability_EOS = 6; + Capability_Ethereum = 7; + Capability_Lisk = 8 [deprecated = true]; + Capability_Monero = 9; + Capability_NEM = 10; + Capability_Ripple = 11; + Capability_Stellar = 12; + Capability_Tezos = 13; + Capability_U2F = 14; + Capability_Shamir = 15 [(bitcoin_only) = true]; + Capability_ShamirGroups = 16 [(bitcoin_only) = true]; + Capability_PassphraseEntry = 17 [(bitcoin_only) = true]; // the device is capable of passphrase entry directly on the device + Capability_Solana = 18; + Capability_Translations = 19 [(bitcoin_only) = true]; + Capability_Brightness = 20 [(bitcoin_only) = true]; + Capability_Haptic = 21 [(bitcoin_only) = true]; + } +} + +/** + * Request: soft-lock the device. Following actions will require PIN. Passphrases remain cached. + * @start + * @next Success + */ +message LockDevice { +} + +/** + * Request: Show a "Do not disconnect" dialog instead of the standard homescreen. + * @start + * @next Success + */ +message SetBusy { + optional uint32 expiry_ms = 1; // The time in milliseconds after which the dialog will automatically disappear. Overrides any previously set expiry. If not set, then the dialog is hidden. +} + +/** + * Request: end the current sesson. Following actions must call Initialize again. + * Cache for the current session is discarded, other sessions remain intact. + * Device is not PIN-locked. + * @start + * @next Success + */ +message EndSession { +} + +/** + * Request: change some property of the device, e.g. label or homescreen + * @start + * @next Success + * @next Failure + */ +message ApplySettings { + optional string language = 1 [deprecated=true]; + optional string label = 2; + optional bool use_passphrase = 3; + optional bytes homescreen = 4; + optional uint32 _passphrase_source = 5 [deprecated=true]; // ASK = 0; DEVICE = 1; HOST = 2; + optional uint32 auto_lock_delay_ms = 6; + optional uint32 display_rotation = 7; // in degrees from North + optional bool passphrase_always_on_device = 8; // do not prompt for passphrase, enforce device entry + optional SafetyCheckLevel safety_checks = 9; // Safety check level, set to Prompt to limit path namespace enforcement + optional bool experimental_features = 10; // enable experimental message types + optional bool hide_passphrase_from_host = 11; // do not show passphrase coming from host + optional bool haptic_feedback = 13; // enable haptic feedback +} + +/** + * Request: change the device language via translation data. + * Does not send the translation data itself, as they are too large for one message. + * Device will request the translation data in chunks. + * @start + * @next TranslationDataRequest + * @next Failure + */ +message ChangeLanguage { + // byte length of the whole translation blob (set to 0 for default language - english) + required uint32 data_length = 1; + // Prompt the user on screen. + // In certain conditions (such as freshly installed device), the confirmation prompt + // is not mandatory. Setting show_display=false will skip the prompt if that's + // the case. If the device does not allow skipping the prompt, a request with + // show_display=false will return a failure. (This way the host can safely try + // to change the language without invoking a prompt.) + // Setting show_display to true will always show the prompt. + // Leaving the option unset will show the prompt only when necessary. + optional bool show_display = 2; +} + +/** + * Response: Device asks for more data from transaction payload. + * @end + * @next TranslationDataAck + */ + message TranslationDataRequest { + required uint32 data_length = 1; // Number of bytes being requested + required uint32 data_offset = 2; // Offset of the first byte being requested +} + +/** + * Request: Translation payload data. + * @next TranslationDataRequest + * @next Success + */ +message TranslationDataAck { + required bytes data_chunk = 1; // Bytes from translation payload +} + +/** + * Request: set flags of the device + * @start + * @next Success + * @next Failure + */ +message ApplyFlags { + required uint32 flags = 1; // bitmask, can only set bits, not unset +} + +/** + * Request: Starts workflow for setting/changing/removing the PIN + * @start + * @next Success + * @next Failure + */ +message ChangePin { + optional bool remove = 1; // is PIN removal requested? +} + +/** + * Request: Starts workflow for setting/removing the wipe code + * @start + * @next Success + * @next Failure + */ +message ChangeWipeCode { + optional bool remove = 1; // is wipe code removal requested? +} + +/** + * Request: Starts workflow for enabling/regenerating/disabling SD card protection + * @start + * @next Success + * @next Failure + */ +message SdProtect { + required SdProtectOperationType operation = 1; + /** + * Structure representing SD card protection operation + */ + enum SdProtectOperationType { + DISABLE = 0; + ENABLE = 1; + REFRESH = 2; + } +} + +/** + * Request: Test if the device is alive, device sends back the message in Success response + * @start + * @next Success + */ +message Ping { + optional string message = 1 [default=""]; // message to send back in Success message + optional bool button_protection = 2; // ask for button press +} + +/** + * Request: Abort last operation that required user interaction + * @start + * @next Failure + */ +message Cancel { +} + +/** + * Request: Request a sample of random data generated by hardware RNG. May be used for testing. + * @start + * @next Entropy + * @next Failure + */ +message GetEntropy { + required uint32 size = 1; // size of requested entropy +} + +/** + * Response: Reply with random data generated by internal RNG + * @end + */ +message Entropy { + required bytes entropy = 1; // chunk of random generated bytes +} + +/** + * Request: Get a hash of the installed firmware combined with an optional challenge. + * @start + * @next FirmwareHash + * @next Failure + */ +message GetFirmwareHash { + optional bytes challenge = 1; // Blake2s key up to 32 bytes in length. +} + +/** + * Response: Hash of the installed firmware combined with the optional challenge. + * @end + */ +message FirmwareHash { + required bytes hash = 1; +} + +/** + * Request: Request a signature of the provided challenge. + * @start + * @next AuthenticityProof + * @next Failure + */ +message AuthenticateDevice { + required bytes challenge = 1; // A random challenge to sign. +} + +/** + * Response: Signature of the provided challenge along with a certificate issued by the Trezor company. + * @end + */ +message AuthenticityProof { + repeated bytes certificates = 1; // A certificate chain starting with the device certificate, followed by intermediate CA certificates, the last of which is signed by Trezor company's root CA. + required bytes signature = 2; // A DER-encoded signature of "\0x13AuthenticateDevice:" + length-prefixed challenge that should be verified using the device certificate. +} + +/** + * Request: Request device to wipe all sensitive data and settings + * @start + * @next Success + * @next Failure + */ +message WipeDevice { +} + +/** + * Request: Load seed and related internal settings from the computer + * @start + * @next Success + * @next Failure + */ +message LoadDevice { + repeated string mnemonics = 1; // seed encoded as mnemonic (12, 18 or 24 words for BIP39, 20 or 33 for SLIP39) + optional string pin = 3; // set PIN protection + optional bool passphrase_protection = 4; // enable master node encryption using passphrase + optional string language = 5 [deprecated=true]; // deprecated (use ChangeLanguage) + optional string label = 6; // device label + optional bool skip_checksum = 7; // do not test mnemonic for valid BIP-39 checksum + optional uint32 u2f_counter = 8; // U2F counter + optional bool needs_backup = 9; // set "needs backup" flag + optional bool no_backup = 10; // indicate that no backup is going to be made +} + +/** + * Request: Ask device to do initialization involving user interaction + * @start + * @next EntropyRequest + * @next Failure + */ +message ResetDevice { + optional bool display_random = 1; // display entropy generated by the device before asking for additional entropy + optional uint32 strength = 2 [default=256]; // strength of seed in bits + optional bool passphrase_protection = 3; // enable master node encryption using passphrase + optional bool pin_protection = 4; // enable PIN protection + optional string language = 5 [deprecated=true]; // deprecated (use ChangeLanguage) + optional string label = 6; // device label + optional uint32 u2f_counter = 7; // U2F counter + optional bool skip_backup = 8; // postpone seed backup to BackupDevice workflow + optional bool no_backup = 9; // indicate that no backup is going to be made + optional BackupType backup_type = 10 [default=Bip39]; // type of the mnemonic backup +} + +/** + * Request: Perform backup of the device seed if not backed up using ResetDevice + * @start + * @next Success + */ +message BackupDevice { + optional uint32 group_threshold = 1; + message Slip39Group { + required uint32 member_threshold = 1; + required uint32 member_count = 2; + } + repeated Slip39Group groups = 2; +} + +/** + * Response: Ask for additional entropy from host computer + * @next EntropyAck + */ +message EntropyRequest { +} + +/** + * Request: Provide additional entropy for seed generation function + * @next Success + */ +message EntropyAck { + required bytes entropy = 1; // 256 bits (32 bytes) of random data +} + +/** + * Request: Start recovery workflow asking user for specific words of mnemonic + * Used to recovery device safely even on untrusted computer. + * @start + * @next WordRequest + */ +message RecoveryDevice { + optional uint32 word_count = 1; // number of words in BIP-39 mnemonic (T1 only) + optional bool passphrase_protection = 2; // enable master node encryption using passphrase + optional bool pin_protection = 3; // enable PIN protection + optional string language = 4 [deprecated=true]; // deprecated (use ChangeLanguage) + optional string label = 5; // device label + optional bool enforce_wordlist = 6; // enforce BIP-39 wordlist during the process (T1 only) + reserved 7; // unused recovery method + optional RecoveryDeviceInputMethod input_method = 8; // supported recovery input method (T1 only) + optional uint32 u2f_counter = 9; // U2F counter + optional RecoveryType type = 10 [default=NormalRecovery]; // the type of recovery to perform + /** + * Type of recovery procedure. These should be used as bitmask, e.g., + * `RecoveryDeviceInputMethod_ScrambledWords | RecoveryDeviceInputMethod_Matrix` + * listing every method supported by the host computer. + * + * Note that ScrambledWords must be supported by every implementation + * for backward compatibility; there is no way to not support it. + */ + enum RecoveryDeviceInputMethod { + // use powers of two when extending this field + ScrambledWords = 0; // words in scrambled order + Matrix = 1; // matrix recovery type + } +} + +enum RecoveryType { + NormalRecovery = 0; // recovery from seedphrase on an uninitialized device + DryRun = 1; // mnemonic validation + UnlockRepeatedBackup = 2; // unlock SLIP-39 repeated backup +} + +/** + * Response: Device is waiting for user to enter word of the mnemonic + * Its position is shown only on device's internal display. + * @next WordAck + */ +message WordRequest { + required WordRequestType type = 1; + /** + * Type of Recovery Word request + */ + enum WordRequestType { + WordRequestType_Plain = 0; + WordRequestType_Matrix9 = 1; + WordRequestType_Matrix6 = 2; + } +} + +/** + * Request: Computer replies with word from the mnemonic + * @next WordRequest + * @next Success + * @next Failure + */ +message WordAck { + required string word = 1; // one word of mnemonic on asked position +} + +/** + * Request: Set U2F counter + * @start + * @next Success + */ +message SetU2FCounter { + required uint32 u2f_counter = 1; +} + +/** + * Request: Set U2F counter + * @start + * @next NextU2FCounter + */ +message GetNextU2FCounter { +} + +/** + * Request: Set U2F counter + * @end + */ +message NextU2FCounter { + required uint32 u2f_counter = 1; +} + +/** + * Request: Ask device to prepare for a preauthorized operation. + * @start + * @next PreauthorizedRequest + * @next Failure + */ +message DoPreauthorized { +} + +/** + * Request: Device awaits a preauthorized operation. + * @start + * @next SignTx + * @next GetOwnershipProof + */ +message PreauthorizedRequest { +} + +/** + * Request: Cancel any outstanding authorization in the current session. + * @start + * @next Success + * @next Failure + */ +message CancelAuthorization { +} + +/** + * Request: Reboot firmware to bootloader + * @start + * @next Success + */ +message RebootToBootloader { + // Action to be performed after rebooting to bootloader + optional BootCommand boot_command = 1 [default=STOP_AND_WAIT]; + // Firmware header to be flashed after rebooting to bootloader + optional bytes firmware_header = 2; + // Length of language blob to be installed before upgrading firmware + optional uint32 language_data_length = 3 [default=0]; + + enum BootCommand { + // Go to bootloader menu + STOP_AND_WAIT = 0; + // Connect to host and wait for firmware update + INSTALL_UPGRADE = 1; + } +} + +/** + * Request: Ask device to generate a random nonce and store it in the session's cache + * @start + * @next Nonce + */ +message GetNonce { + option (experimental_message) = true; +} + +/** + * Response: Contains a random nonce + * @end + */ +message Nonce { + option (experimental_message) = true; + + required bytes nonce = 1; // a 32-byte random value generated by Trezor +} + +/** + * Request: Ask device to unlock a subtree of the keychain. + * @start + * @next UnlockedPathRequest + * @next Failure + */ +message UnlockPath { + repeated uint32 address_n = 1; // prefix of the BIP-32 path leading to the account (m / purpose') + optional bytes mac = 2; // the MAC returned by UnlockedPathRequest +} + +/** + * Request: Device awaits an operation. + * @start + * @next SignTx + * @next GetPublicKey + * @next GetAddress + */ +message UnlockedPathRequest { + optional bytes mac = 1; // authentication code for future UnlockPath calls +} + +/** + * Request: Show tutorial screens on the device + * @start + * @next Success + */ +message ShowDeviceTutorial { +} + +/** + * Request: Unlocks bootloader, !irreversible! + * @start + * @next Success + * @next Failure + */ +message UnlockBootloader { +} + +/** + * Request: Set device brightness + * @start + * @next Success + */ +message SetBrightness { + optional uint32 value = 1; // if not specified, let the user choose +} diff --git a/src/trezor-client.ts b/src/trezor-client.ts index 9011b93..494e8b9 100644 --- a/src/trezor-client.ts +++ b/src/trezor-client.ts @@ -1,5 +1,6 @@ +import { isValidAddress } from "@nomicfoundation/ethereumjs-util"; import { HardhatTrezorError } from "./errors"; -import { TrezorWire } from "./trezor-wire"; +import { TrezorMessageType, TrezorWire } from "./trezor-wire"; export const defaultTrezorBridgeURL = "http://127.0.0.1:21325"; @@ -12,6 +13,35 @@ export class TrezorClient { bridgeURL: string; wire: TrezorWire; + static encodePayload(code: number, data: Uint8Array) { + // BE, 2 bytes (4 hex chars), message type + // BE, 4 bytes (8 hex chars), message length + // message payload + const bytes = new Uint8Array(6 + data.length); + const view = new DataView(bytes.buffer); + view.setUint16(0, code, false); + view.setUint32(2, data.length, false); + bytes.set(data, 6); + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + + static decodePayload(hex: string) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + const view = new DataView(bytes.buffer); + const code = view.getUint16(0, false); + const length = view.getUint32(2, false); + const data = bytes.slice(6); + if (length !== data.length) { + throw new HardhatTrezorError("Invalid response message length"); + } + return { code, data }; + } + constructor(opts: TrezorClientOptions) { let bridgeURL = opts.bridgeURL ?? defaultTrezorBridgeURL; if (bridgeURL.endsWith("/")) { @@ -21,7 +51,8 @@ export class TrezorClient { this.wire = opts.wire; } - async invoke(path: string, body: any = {}) { + async _invoke(path: string, body: any = {}) { + console.log("trezor_client._invoke", path, body); if (!path.startsWith("/")) { path = `/${path}`; } @@ -51,14 +82,14 @@ export class TrezorClient { } async version() { - const resp = await this.invoke("/"); + const resp = await this._invoke("/"); return (await resp.json()) as { version: string; }; } async enumerate() { - const resp = await this.invoke("/enumerate"); + const resp = await this._invoke("/enumerate"); return (await resp.json()) as { path: string; session?: string; @@ -66,61 +97,127 @@ export class TrezorClient { } async acquire(path: string, previous?: string) { - const resp = await this.invoke( + const resp = await this._invoke( `/acquire/${path}/${previous ?? "null"}`, {}, ); return (await resp.json()) as { session: string }; } + async release(session: string) { + await this._invoke(`/release/${session}`, {}); + } + async call( session: string, - type: number, - data: Uint8Array, - ): Promise<{ type: number; data: Uint8Array }> { - // BE, 2 bytes (4 hex chars), message type - // BE, 4 bytes (8 hex chars), message length - // message payload - const reqBytes = new Uint8Array(6 + data.length); - const reqView = new DataView(reqBytes.buffer); - reqView.setUint16(0, type, false); - reqView.setUint32(2, data.length, false); - reqBytes.set(data, 6); - - // convert message to hex string - const reqHex = Array.from(reqBytes) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); + typeIn: TrezorMessageType, + typeOut: TrezorMessageType, + dataIn: any, + ) { + let resp = await this._call(session, typeIn, dataIn); + while (true) { + if (resp.code == this.wire.PinMatrixRequest.code) { + console.log("trezor_client.call got PinMatrixRequest"); + await this._write(session, this.wire.PinMatrixAck, { pin: "000000" }); + resp = await this._read(session); + } else if (resp.code == this.wire.PassphraseRequest.code) { + console.log("trezor_client.call got PassphraseRequest"); + await this._write(session, this.wire.PassphraseAck, { + on_device: true, + }); + resp = await this._read(session); + } else if (resp.code == this.wire.ButtonRequest.code) { + const data = this.wire.ButtonRequest.type.decode(resp.data).toJSON(); + console.log("trezor_client.call got ButtonRequest", data); + await this._write(session, this.wire.ButtonAck, {}); + resp = await this._read(session); + } else if (resp.code == this.wire.Failure.code) { + const error = this.wire.Failure.type.decode(resp.data).toJSON() as { + code?: number; + message?: string; + }; + throw new HardhatTrezorError( + `Trezor failure: ${error.code}:${error.message}`, + ); + } else { + if (resp.code != typeOut.code) { + throw new HardhatTrezorError( + `Unexpected response message type:${resp.code}`, + ); + } + return typeOut.type.decode(resp.data).toJSON(); + } + } + } - const resp = await this.invoke(`/call/${session}`, reqHex); + async _call( + session: string, + typeIn: TrezorMessageType, + dataIn: any, + ): Promise<{ code: number; data: Uint8Array }> { + console.log("trezor_client._call", session, typeIn.name, dataIn); + const resp = await this._invoke( + `/call/${session}`, + TrezorClient.encodePayload( + typeIn.code, + typeIn.type.encode(dataIn).finish(), + ), + ); + return TrezorClient.decodePayload(await resp.text()); + } - const respHex = await resp.text(); + async _write( + session: string, + typeIn: TrezorMessageType, + dataIn: any, + ): Promise { + console.log("trezor_client._write", session, typeIn.name, dataIn); + await this._invoke( + `/post/${session}`, + TrezorClient.encodePayload( + typeIn.code, + typeIn.type.encode(dataIn).finish(), + ), + ); + return; + } - // convert response from hex string to Uint8Array - const respBytes = new Uint8Array(respHex.length / 2); + async _read(session: string): Promise<{ code: number; data: Uint8Array }> { + console.log("trezor_client._read"); + const resp = await this._invoke(`/read/${session}`, ""); + const { code, data } = TrezorClient.decodePayload(await resp.text()); + console.log("trezor_client._read", session, code); + return { code, data }; + } - for (let i = 0; i < respHex.length; i += 2) { - respBytes[i / 2] = parseInt(respHex.slice(i, i + 2), 16); - } + async callInitialize(session: string) { + return this.call(session, this.wire.Initialize, this.wire.Features, {}); + } - // parse response - const respView = new DataView(respBytes.buffer); - const respType = respView.getUint16(0, false); - const respDataLength = respView.getUint32(2, false); - const respData = respBytes.slice(6); - if (respDataLength !== respData.length) { - throw new HardhatTrezorError("Invalid response message length"); - } - if (respType === this.wire.MessageType_Failure) { - const { code, message } = this.wire.Failure.decode(respData) as { - code?: number; - message?: string; - }; - throw new HardhatTrezorError(`Trezor failure: ${code} ${message}`); + async callEndSession(session: string) { + return this.call(session, this.wire.EndSession, this.wire.Success, {}); + } + + async callEthereumGetAddress(session: string, derivationPath: number[]) { + const accounts = []; + + const { address: addressBatch } = (await this.call( + session, + this.wire.EthereumGetAddress, + this.wire.EthereumAddress, + { addressN: derivationPath }, + )) as { address: string }; + + for (let address of addressBatch.split("\n")) { + address = address.trim(); + if (address === "") { + continue; + } + if (!isValidAddress(address)) { + throw new HardhatTrezorError("Invalid address received from Trezor"); + } + accounts.push(address.toLowerCase()); } - return { - type: respType, - data: respData, - }; + return accounts; } } diff --git a/src/trezor-provider.ts b/src/trezor-provider.ts index 8d98c23..dfd314f 100644 --- a/src/trezor-provider.ts +++ b/src/trezor-provider.ts @@ -58,44 +58,37 @@ export class TrezorProvider extends ProviderWrapperWithChainId { const device = devices[0]; + console.log( + "trezor_provider._initializeSession: found ", + device.path, + device.session, + ); + const { session } = await this.client.acquire(device.path, device.session); if (!session) { throw new HardhatTrezorError("Failed to acquire Trezor device"); } + console.log("trezor_provider._initializeSession: acquired ", session); + this.session = session; + + process.on("exit", async () => { + if (this.session) { + await this.client.callEndSession(this.session); + await this.client.release(this.session); + } + }); + + await this.client.callInitialize(this.session); } async _initializeAccounts() { - const accounts = []; - const { type, data } = await this.client.call( + this.accounts = await this.client.callEthereumGetAddress( this.session!, - this.wire.MessageType_EthereumGetAddress, - this.wire.EthereumGetAddress.encode({ - addressN: this.derivationPath, - }).finish(), + this.derivationPath, ); - if (type !== this.wire.MessageType_EthereumAddress) { - throw new HardhatTrezorError(`Unexpected response message type:${type}`); - } - const { address: addressBatch } = this.wire.EthereumAddress.decode( - data, - ).toJSON() as { address: string }; - if (!addressBatch) { - throw new HardhatTrezorError("No address received from Trezor"); - } - for (let address of addressBatch.split("\n")) { - address = address.trim(); - if (address === "") { - continue; - } - if (!isValidAddress(address)) { - throw new HardhatTrezorError("Invalid address received from Trezor"); - } - accounts.push(address.toLowerCase()); - } - this.accounts = accounts; } async initialize() { diff --git a/src/trezor-wire.ts b/src/trezor-wire.ts index 2b377bc..26ccf56 100644 --- a/src/trezor-wire.ts +++ b/src/trezor-wire.ts @@ -1,5 +1,6 @@ import path from "node:path"; import * as protobuf from "protobufjs"; +import { HardhatTrezorError } from "./errors"; export const defaultDerivationPath = [44, 60, 0, 0, 0]; @@ -18,37 +19,94 @@ async function loadProtobufFile(name: string): Promise { }); } +export interface TrezorMessageType { + code: number; + name: string; + type: protobuf.Type; +} + export async function createTrezorWire() { - const rootMessages = await loadProtobufFile("messages"); - const rootMessagesEthereum = await loadProtobufFile("messages-ethereum"); - const rootMessagesCommon = await loadProtobufFile("messages-common"); - const enumMessageType = rootMessages.lookupEnum( - "hw.trezor.messages.MessageType", - )!; - - const { - MessageType_Failure, - MessageType_EthereumGetAddress, - MessageType_EthereumAddress, - } = enumMessageType.values; - - const Failure = rootMessagesCommon.lookupType( - "hw.trezor.messages.common.Failure", - )!; - const EthereumGetAddress = rootMessagesEthereum.lookupType( - "hw.trezor.messages.ethereum.EthereumGetAddress", - )!; - const EthereumAddress = rootMessagesEthereum.lookupType( - "hw.trezor.messages.ethereum.EthereumAddress", - )!; + const roots = [ + { + prefix: "hw.trezor.messages.ethereum.", + root: await loadProtobufFile("messages-ethereum"), + }, + { + prefix: "hw.trezor.messages.management.", + root: await loadProtobufFile("messages-management"), + }, + { + prefix: "hw.trezor.messages.common.", + root: await loadProtobufFile("messages-common"), + }, + { + prefix: "hw.trezor.messages.", + root: await loadProtobufFile("messages"), + }, + ]; + + const lookupEnum = (name: string) => { + for (const root of roots) { + if (name.startsWith(root.prefix)) { + const result = root.root.lookupEnum(name); + if (result) { + return result; + } + } + } + throw new HardhatTrezorError(`Enum not found: ${name}`); + }; + + const messageTypes = lookupEnum("hw.trezor.messages.MessageType")!; + + const lookupType = (name: string) => { + for (const root of roots) { + if (name.startsWith(root.prefix)) { + const result = root.root.lookupType(name); + if (result) { + return result; + } + } + } + throw new HardhatTrezorError(`Type not found: ${name}`); + }; + + const lookupMessageType = (name: string): TrezorMessageType => { + const basename = name.split(".").pop()!; + const type = lookupType(name); + const code = messageTypes.values["MessageType_" + basename]; + if (code === undefined) { + throw new HardhatTrezorError(`Message type not found: ${name}`); + } + return { code: code, type: type, name: basename }; + }; return { - MessageType_Failure, - MessageType_EthereumGetAddress, - MessageType_EthereumAddress, - Failure, - EthereumGetAddress, - EthereumAddress, + // common + Success: lookupMessageType("hw.trezor.messages.common.Success"), + ButtonRequest: lookupMessageType("hw.trezor.messages.common.ButtonRequest"), + ButtonAck: lookupMessageType("hw.trezor.messages.common.ButtonAck"), + PinMatrixRequest: lookupMessageType( + "hw.trezor.messages.common.PinMatrixRequest", + ), + PinMatrixAck: lookupMessageType("hw.trezor.messages.common.PinMatrixAck"), + PassphraseRequest: lookupMessageType( + "hw.trezor.messages.common.PassphraseRequest", + ), + PassphraseAck: lookupMessageType("hw.trezor.messages.common.PassphraseAck"), + Failure: lookupMessageType("hw.trezor.messages.common.Failure"), + // ethereum + EthereumGetAddress: lookupMessageType( + "hw.trezor.messages.ethereum.EthereumGetAddress", + ), + EthereumAddress: lookupMessageType( + "hw.trezor.messages.ethereum.EthereumAddress", + ), + // management + Initialize: lookupMessageType("hw.trezor.messages.management.Initialize"), + EndSession: lookupMessageType("hw.trezor.messages.management.EndSession"), + GetFeatures: lookupMessageType("hw.trezor.messages.management.GetFeatures"), + Features: lookupMessageType("hw.trezor.messages.management.Features"), }; }