mirror of
https://github.com/trezor/trezor-firmware.git
synced 2024-12-18 12:28:09 +00:00
feat(python): ScriptUI for trezorctl, reference client implementation
This commit is contained in:
parent
49a27a0b3c
commit
e3d366e65b
1
python/.changelog.d/2023.added
Normal file
1
python/.changelog.d/2023.added
Normal file
@ -0,0 +1 @@
|
|||||||
|
Add ScriptUI for trezorctl, spawned by --script option
|
@ -23,10 +23,13 @@ import click
|
|||||||
|
|
||||||
from .. import exceptions
|
from .. import exceptions
|
||||||
from ..client import TrezorClient
|
from ..client import TrezorClient
|
||||||
from ..transport import Transport, get_transport
|
from ..transport import get_transport
|
||||||
from ..ui import ClickUI
|
from ..ui import ClickUI, ScriptUI
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from ..transport import Transport
|
||||||
|
from ..ui import TrezorClientUI
|
||||||
|
|
||||||
# Needed to enforce a return value from decorators
|
# Needed to enforce a return value from decorators
|
||||||
# More details: https://www.python.org/dev/peps/pep-0612/
|
# More details: https://www.python.org/dev/peps/pep-0612/
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
@ -50,13 +53,18 @@ class ChoiceType(click.Choice):
|
|||||||
|
|
||||||
class TrezorConnection:
|
class TrezorConnection:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, path: str, session_id: Optional[bytes], passphrase_on_host: bool
|
self,
|
||||||
|
path: str,
|
||||||
|
session_id: Optional[bytes],
|
||||||
|
passphrase_on_host: bool,
|
||||||
|
script: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.passphrase_on_host = passphrase_on_host
|
self.passphrase_on_host = passphrase_on_host
|
||||||
|
self.script = script
|
||||||
|
|
||||||
def get_transport(self) -> Transport:
|
def get_transport(self) -> "Transport":
|
||||||
try:
|
try:
|
||||||
# look for transport without prefix search
|
# look for transport without prefix search
|
||||||
return get_transport(self.path, prefix_search=False)
|
return get_transport(self.path, prefix_search=False)
|
||||||
@ -68,7 +76,13 @@ class TrezorConnection:
|
|||||||
# if this fails, we want the exception to bubble up to the caller
|
# if this fails, we want the exception to bubble up to the caller
|
||||||
return get_transport(self.path, prefix_search=True)
|
return get_transport(self.path, prefix_search=True)
|
||||||
|
|
||||||
def get_ui(self) -> ClickUI:
|
def get_ui(self) -> "TrezorClientUI":
|
||||||
|
if self.script:
|
||||||
|
# It is alright to return just the class object instead of instance,
|
||||||
|
# as the ScriptUI class object itself is the implementation of TrezorClientUI
|
||||||
|
# (ScriptUI is just a set of staticmethods)
|
||||||
|
return ScriptUI # type: ignore [Expression of type "Type[ScriptUI]" cannot be assigned to return type "TrezorClientUI"]
|
||||||
|
else:
|
||||||
return ClickUI(passphrase_on_host=self.passphrase_on_host)
|
return ClickUI(passphrase_on_host=self.passphrase_on_host)
|
||||||
|
|
||||||
def get_client(self) -> TrezorClient:
|
def get_client(self) -> TrezorClient:
|
||||||
|
@ -155,6 +155,12 @@ def configure_logging(verbose: int) -> None:
|
|||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="Enter passphrase on host.",
|
help="Enter passphrase on host.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"-S",
|
||||||
|
"--script",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use UI for usage in scripts.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-s",
|
"-s",
|
||||||
"--session-id",
|
"--session-id",
|
||||||
@ -170,6 +176,7 @@ def cli_main(
|
|||||||
verbose: int,
|
verbose: int,
|
||||||
is_json: bool,
|
is_json: bool,
|
||||||
passphrase_on_host: bool,
|
passphrase_on_host: bool,
|
||||||
|
script: bool,
|
||||||
session_id: Optional[str],
|
session_id: Optional[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
configure_logging(verbose)
|
configure_logging(verbose)
|
||||||
@ -181,7 +188,7 @@ def cli_main(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise click.ClickException(f"Not a valid session id: {session_id}")
|
raise click.ClickException(f"Not a valid session id: {session_id}")
|
||||||
|
|
||||||
ctx.obj = TrezorConnection(path, bytes_session_id, passphrase_on_host)
|
ctx.obj = TrezorConnection(path, bytes_session_id, passphrase_on_host, script)
|
||||||
|
|
||||||
|
|
||||||
# Creating a cli function that has the right types for future usage
|
# Creating a cli function that has the right types for future usage
|
||||||
@ -189,10 +196,14 @@ cli = cast(TrezorctlGroup, cli_main)
|
|||||||
|
|
||||||
|
|
||||||
@cli.resultcallback()
|
@cli.resultcallback()
|
||||||
def print_result(res: Any, is_json: bool, **kwargs: Any) -> None:
|
def print_result(res: Any, is_json: bool, script: bool, **kwargs: Any) -> None:
|
||||||
if is_json:
|
if is_json:
|
||||||
if isinstance(res, protobuf.MessageType):
|
if isinstance(res, protobuf.MessageType):
|
||||||
click.echo(json.dumps({res.__class__.__name__: res.__dict__}))
|
res = protobuf.to_dict(res, hexlify_bytes=True)
|
||||||
|
|
||||||
|
# No newlines for scripts, pretty-print for users
|
||||||
|
if script:
|
||||||
|
click.echo(json.dumps(res))
|
||||||
else:
|
else:
|
||||||
click.echo(json.dumps(res, sort_keys=True, indent=4))
|
click.echo(json.dumps(res, sort_keys=True, indent=4))
|
||||||
else:
|
else:
|
||||||
|
@ -172,6 +172,56 @@ class ClickUI:
|
|||||||
raise Cancelled from None
|
raise Cancelled from None
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptUI:
|
||||||
|
"""Interface to be used by scripts, not directly by user.
|
||||||
|
|
||||||
|
Communicates with a client application using print() and input().
|
||||||
|
|
||||||
|
Lot of `ClickUI` logic is outsourced to the client application, which
|
||||||
|
is responsible for supplying the PIN and passphrase.
|
||||||
|
|
||||||
|
Reference client implementation can be found under `tools/trezorctl_script_client.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def button_request(br: messages.ButtonRequest) -> None:
|
||||||
|
# TODO: send name={br.name} when it will be supported
|
||||||
|
code = br.code.name if br.code else None
|
||||||
|
print(f"?BUTTON code={code} pages={br.pages}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_pin(code: Optional[PinMatrixRequestType] = None) -> str:
|
||||||
|
if code is None:
|
||||||
|
print("?PIN")
|
||||||
|
else:
|
||||||
|
print(f"?PIN code={code.name}")
|
||||||
|
|
||||||
|
pin = input()
|
||||||
|
if pin == "CANCEL":
|
||||||
|
raise Cancelled from None
|
||||||
|
elif not pin.startswith(":"):
|
||||||
|
raise RuntimeError("Sent PIN must start with ':'")
|
||||||
|
else:
|
||||||
|
return pin[1:]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_passphrase(available_on_device: bool) -> Union[str, object]:
|
||||||
|
if available_on_device:
|
||||||
|
print("?PASSPHRASE available_on_device")
|
||||||
|
else:
|
||||||
|
print("?PASSPHRASE")
|
||||||
|
|
||||||
|
passphrase = input()
|
||||||
|
if passphrase == "CANCEL":
|
||||||
|
raise Cancelled from None
|
||||||
|
elif passphrase == "ON_DEVICE":
|
||||||
|
return PASSPHRASE_ON_DEVICE
|
||||||
|
elif not passphrase.startswith(":"):
|
||||||
|
raise RuntimeError("Sent passphrase must start with ':'")
|
||||||
|
else:
|
||||||
|
return passphrase[1:]
|
||||||
|
|
||||||
|
|
||||||
def mnemonic_words(
|
def mnemonic_words(
|
||||||
expand: bool = False, language: str = "english"
|
expand: bool = False, language: str = "english"
|
||||||
) -> Callable[[WordRequestType], str]:
|
) -> Callable[[WordRequestType], str]:
|
||||||
|
134
python/tools/trezorctl_script_client.py
Normal file
134
python/tools/trezorctl_script_client.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Reference client implementation consuming trezorctl's script interface
|
||||||
|
(ScriptUI class) available by using `--script` flag in any trezorctl command.
|
||||||
|
|
||||||
|
Function `get_address()` is showing the communication with ScriptUI
|
||||||
|
on a specific example
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args_from_line(line: str) -> Tuple[str, Dict[str, Union[str, bool]]]:
|
||||||
|
# ?PIN code=123
|
||||||
|
# ?PASSPHRASE available_on_device
|
||||||
|
command, *args = line.split(" ")
|
||||||
|
result: Dict[str, Union[str, bool]] = {}
|
||||||
|
for arg in args:
|
||||||
|
if "=" in arg:
|
||||||
|
key, value = arg.split("=")
|
||||||
|
result[key] = value
|
||||||
|
else:
|
||||||
|
result[arg] = True
|
||||||
|
return command, result
|
||||||
|
|
||||||
|
|
||||||
|
def get_pin_from_user(code: Optional[str] = None) -> str:
|
||||||
|
# ?PIN
|
||||||
|
# ?PIN code=Current
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
pin = click.prompt(
|
||||||
|
f"Enter PIN (code: {code})",
|
||||||
|
hide_input=True,
|
||||||
|
default="",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
except click.Abort:
|
||||||
|
return "CANCEL"
|
||||||
|
if not all(c in "123456789" for c in pin):
|
||||||
|
click.echo("PIN must only be numbers 1-9")
|
||||||
|
continue
|
||||||
|
return ":" + pin
|
||||||
|
|
||||||
|
|
||||||
|
def show_button_request(
|
||||||
|
code: Optional[str] = None, pages: Optional[str] = None, name: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
# ?BUTTON code=Other
|
||||||
|
# ?BUTTON code=SignTx pages=2
|
||||||
|
# ?BUTTON code=ProtectCall name=confirm_set_pin
|
||||||
|
print(f"Please confirm action on Trezor (code={code} name={name} pages={pages})")
|
||||||
|
|
||||||
|
|
||||||
|
def get_passphrase_from_user(available_on_device: bool = False) -> str:
|
||||||
|
# ?PASSPHRASE
|
||||||
|
# ?PASSPHRASE available_on_device
|
||||||
|
if available_on_device:
|
||||||
|
if click.confirm("Enter passphrase on device?", default=True):
|
||||||
|
return "ON_DEVICE"
|
||||||
|
|
||||||
|
env_passphrase = os.getenv("PASSPHRASE")
|
||||||
|
if env_passphrase:
|
||||||
|
if click.confirm("Use env PASSPHRASE?", default=False):
|
||||||
|
return ":" + env_passphrase
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
passphrase = click.prompt("Enter passphrase", hide_input=True, default="")
|
||||||
|
except click.Abort:
|
||||||
|
return "CANCEL"
|
||||||
|
|
||||||
|
passphrase2 = click.prompt(
|
||||||
|
"Enter passphrase again", hide_input=True, default=""
|
||||||
|
)
|
||||||
|
if passphrase != passphrase2:
|
||||||
|
click.echo("Passphrases do not match")
|
||||||
|
continue
|
||||||
|
return ":" + passphrase
|
||||||
|
|
||||||
|
|
||||||
|
def get_address() -> str:
|
||||||
|
args = """
|
||||||
|
trezorctl --script get-address -n "m/49'/0'/0'/0/0"
|
||||||
|
""".strip()
|
||||||
|
p = subprocess.Popen( # type: ignore [No overloads for "__new__" match the provided arguments]
|
||||||
|
args,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
shell=True,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert p.stdout is not None
|
||||||
|
assert p.stdin is not None
|
||||||
|
|
||||||
|
text_result: List[str] = []
|
||||||
|
while True:
|
||||||
|
line = p.stdout.readline().strip()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
if line.startswith("?"):
|
||||||
|
command, args = parse_args_from_line(line)
|
||||||
|
if command == "?PIN":
|
||||||
|
response = get_pin_from_user(**args)
|
||||||
|
p.stdin.write(response + "\n")
|
||||||
|
elif command == "?PASSPHRASE":
|
||||||
|
response = get_passphrase_from_user(**args)
|
||||||
|
p.stdin.write(response + "\n")
|
||||||
|
elif command == "?BUTTON":
|
||||||
|
show_button_request(**args)
|
||||||
|
else:
|
||||||
|
print("Unrecognized script command:", line)
|
||||||
|
|
||||||
|
text_result.append(line)
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
address = text_result[-1]
|
||||||
|
print("Address:", address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_to_enable_pin():
|
||||||
|
os.system("trezorctl clear-session")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
get_address()
|
||||||
|
clear_session_to_enable_pin()
|
Loading…
Reference in New Issue
Block a user