Handshake Ledger app
Some checks failed
Checks on the Python tests / Call Ledger Python linters (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Build application using the reusable workflow (push) Has been cancelled
Run coding style check / Check linting using the reusable workflow (push) Has been cancelled
Ensure compliance with Ledger guidelines / Call Ledger guidelines_enforcer (push) Has been cancelled
Misspellings checks / Check misspellings (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Run standalone ragger tests using the reusable workflow (push) Has been cancelled
Some checks failed
Checks on the Python tests / Call Ledger Python linters (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Build application using the reusable workflow (push) Has been cancelled
Run coding style check / Check linting using the reusable workflow (push) Has been cancelled
Ensure compliance with Ledger guidelines / Call Ledger guidelines_enforcer (push) Has been cancelled
Misspellings checks / Check misspellings (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Run standalone ragger tests using the reusable workflow (push) Has been cancelled
Hardware-wallet app for Handshake (HNS). Derives Handshake addresses and signs
Handshake transactions, including name-auction covenants, on Ledger Stax, Flex,
Nano S+, Nano X and Apex+.
The design premise is that the host computer is untrusted: every APDU byte is
attacker-controlled, and the device's job is to be correct when the host lies.
Two failures outrank all others, and the code is shaped around them.
Signing something other than what the user approved. Any field the signature
commits to and the host can vary has to appear on the review screen, and
anything the screen cannot represent honestly is refused rather than shown under
a label that does not describe it. So the device signs SIGHASH_ALL only,
checked per input rather than once for the session; it displays a TRANSFER
covenant's destination, which decides who ends up owning the name and appears
nowhere else in the output; it shows a covenant's name hash when the plaintext
name cannot be verified against it; and it refuses covenant kinds it cannot
name, outputs whose values exceed their inputs, and declared input totals that
would overflow the fee arithmetic.
Misusing key material. Derivation paths are constrained to
m/44'/{5353..5356}'/account'[/change/index] in the app itself, not only by the
install manifest, because BOLOS terminates the app on an out-of-whitelist path
instead of returning an error. One coin type per signing session, since the
review renders every address under a single HRP.
Panics count too: set_panic!(exiting_panic) means a panic kills the app and
strands the session, so nothing unwraps on host-derived data and the session
ceilings are sized to the configured heap.
Consensus follows shd, the Swift Handshake node, which is the reference for the
sighash preimage, covenant item layouts and address encoding.
SIGN_TX is a PSBT-style state machine driven by P1 (BEGIN / ADD_INPUT /
ADD_OUTPUT / REVIEW / SIGN_INPUT): the host streams the transaction as
structured records, the device accumulates its own view of the values, shows one
review, then signs each input on demand.
Tests run under speculos in a container (./scripts/test), 66 of them, green on
all five devices. test_sign_tx_policy.py covers each refusal above and needs no
golden snapshots because every case is rejected before anything is drawn; the
snapshots that do exist are the record of what the user sees before approving.
tests/application_client/handshake_sighash.py is a deliberately independent
reimplementation of the sighash, used to verify real signatures.
Status: v0.1, pre-audit. Not for mainnet keys. Signing has not yet been
exercised on physical hardware.
Forked from LedgerHQ/app-boilerplate-rust (Apache-2.0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
70
scripts/build
Executable file
70
scripts/build
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Handshake Ledger app for a given device, with all the macOS
|
||||
# native-toolchain env wrangling that cargo-ledger and the secure SDK assume.
|
||||
#
|
||||
# Usage: ./scripts/build [stax|flex|nanox|nanosplus] (default: stax)
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# brew install --cask gcc-arm-embedded # ARM bare-metal toolchain
|
||||
# brew install llvm # llvm-objcopy, llvm-nm
|
||||
# cargo install --git https://github.com/LedgerHQ/cargo-ledger cargo-ledger
|
||||
# cargo ledger setup # installs target.json files
|
||||
# git clone --depth=1 --branch API_LEVEL_25 \
|
||||
# https://github.com/LedgerHQ/ledger-secure-sdk ~/.ledger-sdk/secure-sdk-25
|
||||
# # API level must match the device firmware:
|
||||
# # Stax SE 1.9.1 → API_LEVEL_25.
|
||||
# # If install fails with status 0x511f, you're probably on the wrong level:
|
||||
# # re-clone the SDK at the matching API_LEVEL_NN branch and override
|
||||
# # LEDGER_SDK_PATH for the build.
|
||||
# python3 -m pip install --user --break-system-packages Pillow
|
||||
# # Patch the rustup-installed link_wrap.sh to be macOS-portable:
|
||||
# sed -i '' 's|stat -c %s|wc -c <|' \
|
||||
# ~/.rustup/toolchains/$(rustup show active-toolchain | cut -d' ' -f1)/lib/rustlib/$(rustc -vV | sed -n 's/host: //p')/bin/link_wrap.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEVICE="${1:-stax}"
|
||||
ARM_BIN="/Applications/ArmGNUToolchain/15.2.rel1/arm-none-eabi/bin"
|
||||
LLVM_BIN="/opt/homebrew/opt/llvm/bin"
|
||||
SDK="${LEDGER_SDK_PATH:-$HOME/.ledger-sdk/secure-sdk-25}"
|
||||
|
||||
if [[ ! -x "$ARM_BIN/arm-none-eabi-gcc" ]]; then
|
||||
echo "ARM toolchain not found at $ARM_BIN: install with: brew install --cask gcc-arm-embedded"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$LLVM_BIN/llvm-objcopy" ]]; then
|
||||
echo "llvm-objcopy not found at $LLVM_BIN: install with: brew install llvm"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$SDK/install_params.py" ]]; then
|
||||
echo "BOLOS Secure SDK not found at $SDK: see prerequisites in this script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The Nano NBGL sources use `uint`, a BSD spelling that arm-none-eabi-gcc
|
||||
# accepts and the clang this script drives does not, so nanosplus and nanox
|
||||
# fail to compile out of the box. `uint32_t` is the right width for the two
|
||||
# loop counters that use it (lib_nbgl/src/nbgl_use_case_nanos.c:373,1581).
|
||||
EXTRA_CFLAGS=""
|
||||
if [[ "$DEVICE" == "nanosplus" || "$DEVICE" == "nanox" ]]; then
|
||||
EXTRA_CFLAGS="-Duint=uint32_t"
|
||||
fi
|
||||
|
||||
PATH="$ARM_BIN:$LLVM_BIN:$PATH" \
|
||||
LEDGER_SDK_PATH="$SDK" \
|
||||
CFLAGS="${CFLAGS:-} $EXTRA_CFLAGS" \
|
||||
cargo ledger build "$DEVICE" "${@:2}"
|
||||
|
||||
# cargo-ledger's post-build expects an Intel HEX next to the manifest but
|
||||
# silently produces nothing on macOS: generate it from the ELF.
|
||||
ELF="target/$DEVICE/release/app-handshake"
|
||||
HEX="$ELF.hex"
|
||||
if [[ -f "$ELF" && ( ! -f "$HEX" || "$ELF" -nt "$HEX" ) ]]; then
|
||||
"$LLVM_BIN/llvm-objcopy" -O ihex "$ELF" "$HEX"
|
||||
echo "wrote $HEX"
|
||||
fi
|
||||
|
||||
# cargo-ledger also fails to write the ledgerctl manifest on macOS. Generate it
|
||||
# from the ELF, so `dataSize` can never drift from the binary being installed:
|
||||
# an undersized dataSize installs cleanly and then refuses to launch.
|
||||
"$(dirname "$0")/gen-manifest" "$DEVICE"
|
||||
101
scripts/build-unsigned-tx
Executable file
101
scripts/build-unsigned-tx
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a Handshake unsigned base transaction (no witnesses) from inputs and outputs.
|
||||
|
||||
Mirrors the wire format from shd `Sources/Protocol/Transaction.swift`:
|
||||
version(4 LE) || varint(in_count) || inputs[40B each] || varint(out_count) || outputs || locktime(4 LE)
|
||||
|
||||
For each output:
|
||||
value(8 LE) || address(version(1)+hashLen(1)+hash) || covenant(type(1)+varint(0))
|
||||
|
||||
Defaults: version=0, sequence=0xFFFFFFFF, locktime=0, covenant=.none.
|
||||
|
||||
Usage:
|
||||
./scripts/build-unsigned-tx \\
|
||||
--input 865d9e8011815fbfeb168bff83767f039cd6ac71bd75893d74fb89a3d932322d:0 \\
|
||||
--output hs1qdydhjsla4ahvy5tn6w44ax7y62mpsd5nk3qnvw:9999000
|
||||
|
||||
(Amounts are in dollarydoos. 1 HNS = 1,000,000 dollarydoos.)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import bech32 # type: ignore[import-untyped]
|
||||
|
||||
|
||||
def encode_varint(n: int) -> bytes:
|
||||
if n < 0xFD:
|
||||
return bytes([n])
|
||||
if n <= 0xFFFF:
|
||||
return b"\xfd" + n.to_bytes(2, "little")
|
||||
if n <= 0xFFFFFFFF:
|
||||
return b"\xfe" + n.to_bytes(4, "little")
|
||||
return b"\xff" + n.to_bytes(8, "little")
|
||||
|
||||
|
||||
def parse_input(s: str) -> tuple[bytes, int]:
|
||||
"""`<txid_hex>:<vout>`: txid is natural byte order (matches shd .hex)."""
|
||||
txid_str, vout_str = s.split(":")
|
||||
txid = bytes.fromhex(txid_str)
|
||||
if len(txid) != 32:
|
||||
raise ValueError(f"input txid must be 32 bytes, got {len(txid)}")
|
||||
return txid, int(vout_str)
|
||||
|
||||
|
||||
def parse_output(s: str, network_hrp: str) -> tuple[int, int, bytes]:
|
||||
"""`<bech32_addr>:<amount_in_doos>` → (amount, witness_version, hash_bytes)."""
|
||||
addr, amount_str = s.split(":")
|
||||
hrp, data = bech32.bech32_decode(addr)
|
||||
if hrp != network_hrp:
|
||||
raise ValueError(f"address HRP {hrp!r} != expected {network_hrp!r}")
|
||||
if data is None or not data:
|
||||
raise ValueError(f"invalid bech32 address: {addr}")
|
||||
witver = data[0]
|
||||
program = bech32.convertbits(data[1:], 5, 8, False)
|
||||
if program is None:
|
||||
raise ValueError(f"invalid bech32 program: {addr}")
|
||||
return int(amount_str), witver, bytes(program)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--input", action="append", required=True,
|
||||
help="`<txid>:<vout>` (repeat for multiple inputs)")
|
||||
ap.add_argument("--output", action="append", required=True,
|
||||
help="`<bech32_addr>:<amount_doos>` (repeat for multiple outputs)")
|
||||
ap.add_argument("--version", type=int, default=0)
|
||||
ap.add_argument("--locktime", type=int, default=0)
|
||||
ap.add_argument("--sequence", type=lambda s: int(s, 0), default=0xFFFFFFFF)
|
||||
ap.add_argument("--network", choices=["main", "testnet", "regtest", "simnet"],
|
||||
default="main")
|
||||
args = ap.parse_args()
|
||||
|
||||
hrp_map = {"main": "hs", "testnet": "ts", "regtest": "rs", "simnet": "ss"}
|
||||
hrp = hrp_map[args.network]
|
||||
|
||||
out = bytearray()
|
||||
out += args.version.to_bytes(4, "little")
|
||||
|
||||
inputs = [parse_input(i) for i in args.input]
|
||||
out += encode_varint(len(inputs))
|
||||
for txid, vout in inputs:
|
||||
out += txid
|
||||
out += vout.to_bytes(4, "little")
|
||||
out += args.sequence.to_bytes(4, "little")
|
||||
|
||||
outputs = [parse_output(o, hrp) for o in args.output]
|
||||
out += encode_varint(len(outputs))
|
||||
for amount, witver, program in outputs:
|
||||
out += amount.to_bytes(8, "little")
|
||||
# Address: version(1) + hashLen(1) + hash
|
||||
out += bytes([witver, len(program)]) + program
|
||||
# Covenant.none: type(0) + varint(0 items)
|
||||
out += b"\x00\x00"
|
||||
|
||||
out += args.locktime.to_bytes(4, "little")
|
||||
|
||||
print(out.hex())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
96
scripts/gen-manifest
Executable file
96
scripts/gen-manifest
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write the ledgerctl install manifest for a built app.
|
||||
|
||||
cargo-ledger does not emit `app_<device>.json` on macOS, so generate it from
|
||||
the built ELF plus `[package.metadata.ledger]` in Cargo.toml.
|
||||
|
||||
Everything that can be read from the ELF is read from the ELF: app name,
|
||||
version, api level and target id all come from its `ledger.*` sections, so the
|
||||
manifest cannot drift from the binary it installs.
|
||||
|
||||
`dataSize` is `_envram_data - _nvram_data`: the NVRAM the app is granted at
|
||||
install time. Getting it too small installs an app that will not launch.
|
||||
|
||||
Usage: ./scripts/gen-manifest [stax|flex|nanox|nanosplus] (default: stax)
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
LLVM_NM = "/opt/homebrew/opt/llvm/bin/llvm-nm"
|
||||
LLVM_OBJCOPY = "/opt/homebrew/opt/llvm/bin/llvm-objcopy"
|
||||
|
||||
# cargo-ledger's Cargo.toml key -> the [package.metadata.ledger.<key>] table
|
||||
DEVICE_TABLE = {
|
||||
"stax": "stax", "flex": "flex",
|
||||
"nanox": "nanox", "nanosplus": "nanosplus", "apex_p": "apex_p",
|
||||
}
|
||||
|
||||
|
||||
def elf_section(elf, name):
|
||||
"""Read a `ledger.*` metadata string section out of the ELF."""
|
||||
out = subprocess.run(
|
||||
[LLVM_OBJCOPY, f"--dump-section=ledger.{name}=/dev/stdout", elf, "/dev/null"],
|
||||
capture_output=True,
|
||||
).stdout
|
||||
return out.decode("utf-8", "replace").strip("\0\n ")
|
||||
|
||||
|
||||
def data_size(elf):
|
||||
"""NVRAM granted to the app: `_envram_data - _nvram_data`."""
|
||||
out = subprocess.run([LLVM_NM, elf], capture_output=True, text=True).stdout
|
||||
sym = {}
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 3 and parts[2] in ("_nvram_data", "_envram_data"):
|
||||
sym[parts[2]] = int(parts[0], 16)
|
||||
missing = {"_nvram_data", "_envram_data"} - sym.keys()
|
||||
if missing:
|
||||
sys.exit(f"gen-manifest: {elf} has no {', '.join(sorted(missing))} symbol")
|
||||
return sym["_envram_data"] - sym["_nvram_data"]
|
||||
|
||||
|
||||
def main():
|
||||
device = sys.argv[1] if len(sys.argv) > 1 else "stax"
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
cargo = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
pkg = cargo["package"]
|
||||
meta = pkg["metadata"]["ledger"]
|
||||
per_device = meta.get(DEVICE_TABLE.get(device, device), {})
|
||||
|
||||
elf = root / "target" / device / "release" / pkg["name"]
|
||||
if not elf.is_file():
|
||||
sys.exit(f"gen-manifest: {elf} not built: run ./scripts/build {device} first")
|
||||
hex_path = elf.with_suffix(".hex")
|
||||
if not hex_path.is_file():
|
||||
sys.exit(f"gen-manifest: {hex_path} missing: run ./scripts/build {device} first")
|
||||
|
||||
icon = per_device.get("icon")
|
||||
if not icon:
|
||||
sys.exit(f"gen-manifest: no icon for '{device}' in [package.metadata.ledger.{device}]")
|
||||
|
||||
flags = meta.get("flags", "0")
|
||||
manifest = {
|
||||
"name": elf_section(elf, "app_name"),
|
||||
"version": elf_section(elf, "app_version"),
|
||||
"icon": str(root / icon),
|
||||
"targetId": elf_section(elf, "target_id"),
|
||||
"flags": f"0x{int(str(flags), 0):03x}",
|
||||
"apiLevel": elf_section(elf, "api_level"),
|
||||
"derivationPath": {"curves": meta["curve"], "paths": meta["path"]},
|
||||
"binary": hex_path.name,
|
||||
"dataSize": data_size(elf),
|
||||
}
|
||||
|
||||
out = elf.parent / f"app_{device}.json"
|
||||
out.write_text(json.dumps(manifest, indent=4) + "\n")
|
||||
print(f"wrote {out.relative_to(root)} "
|
||||
f"(name={manifest['name']} apiLevel={manifest['apiLevel']} "
|
||||
f"dataSize={manifest['dataSize']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
scripts/send-apdu
Executable file
47
scripts/send-apdu
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send a raw APDU (hex) to whatever app is currently open on a connected Ledger.
|
||||
|
||||
Usage:
|
||||
./scripts/send-apdu E005010015058000002C8000374F800000000000000000000000
|
||||
|
||||
Or pipe:
|
||||
echo E005010015058000002C8000374F800000000000000000000000 | ./scripts/send-apdu
|
||||
"""
|
||||
import sys
|
||||
|
||||
from ledgerwallet.transport import enumerate_devices
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1:
|
||||
hex_apdu = sys.argv[1]
|
||||
else:
|
||||
hex_apdu = sys.stdin.read().strip()
|
||||
hex_apdu = hex_apdu.replace(" ", "").replace("\n", "")
|
||||
if not hex_apdu:
|
||||
print("usage: send-apdu <hex-apdu>", file=sys.stderr)
|
||||
return 2
|
||||
apdu = bytes.fromhex(hex_apdu)
|
||||
|
||||
devices = enumerate_devices()
|
||||
if not devices:
|
||||
print("no Ledger device found", file=sys.stderr)
|
||||
return 1
|
||||
transport = devices[0]
|
||||
transport.open()
|
||||
try:
|
||||
print(f"-> {apdu.hex().upper()}")
|
||||
# 60s timeout to give the user time to confirm on-device.
|
||||
response = transport.exchange(apdu, timeout=60000)
|
||||
if len(response) < 2:
|
||||
print(f"<- short response: {response.hex().upper()}")
|
||||
return 1
|
||||
data, sw = response[:-2], int.from_bytes(response[-2:], "big")
|
||||
print(f"<- sw={sw:04x} data={data.hex().upper()} ({len(data)} bytes)")
|
||||
finally:
|
||||
transport.close()
|
||||
return 0 if response[-2:] == b"\x90\x00" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
218
scripts/sign-tx
Executable file
218
scripts/sign-tx
Executable file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end SIGN_TX driver for the PSBT-style state machine (INS 0x06).
|
||||
|
||||
Streams an unsigned Handshake tx to the Ledger app as structured sub-ops
|
||||
(BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), then assembles the
|
||||
signed tx with one witness per input.
|
||||
|
||||
Usage:
|
||||
./scripts/sign-tx \
|
||||
--path "44'/5353'/0'/0/0" \
|
||||
--input 865d9e8011815fbfeb168bff83767f039cd6ac71bd75893d74fb89a3d932322d:0:10000000 \
|
||||
--output hs1qdydhjsla4ahvy5tn6w44ax7y62mpsd5ny4jlej:9999000
|
||||
|
||||
Amounts are in dollarydoos. 1 HNS = 1,000,000 dollarydoos.
|
||||
Exit code 0 = signed; nonzero = error or device rejection.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import bech32 # type: ignore[import-untyped]
|
||||
from ledgerwallet.transport import enumerate_devices
|
||||
|
||||
CLA = 0xE0
|
||||
INS_GET_PUBKEY = 0x05
|
||||
INS_SIGN_TX = 0x06
|
||||
|
||||
P1_BEGIN = 0x00
|
||||
P1_ADD_INPUT = 0x01
|
||||
P1_ADD_OUTPUT = 0x02
|
||||
P1_REVIEW = 0x03
|
||||
P1_SIGN_INPUT = 0x04
|
||||
|
||||
P2_LAST = 0x00
|
||||
P2_MORE = 0x80
|
||||
|
||||
SCRIPT_KIND_P2WPKH = 0x00
|
||||
MAX_DATA = 240
|
||||
|
||||
HRP = {"main": "hs", "testnet": "ts", "regtest": "rs", "simnet": "ss"}
|
||||
|
||||
|
||||
def open_device():
|
||||
devs = enumerate_devices()
|
||||
if not devs:
|
||||
print("no Ledger device found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
t = devs[0]
|
||||
t.open()
|
||||
return t
|
||||
|
||||
|
||||
def parse_path(s: str) -> bytes:
|
||||
"""`count(1) || components[count](BE u32, hardened bit set on quoted parts)`."""
|
||||
parts = [p for p in s.lstrip("m/").split("/") if p]
|
||||
out = bytearray([len(parts)])
|
||||
for p in parts:
|
||||
n = int(p.rstrip("'"))
|
||||
if p.endswith("'"):
|
||||
n |= 0x80000000
|
||||
out.extend(n.to_bytes(4, "big"))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_varint(n: int) -> bytes:
|
||||
if n < 0xFD:
|
||||
return bytes([n])
|
||||
if n <= 0xFFFF:
|
||||
return b"\xfd" + n.to_bytes(2, "little")
|
||||
if n <= 0xFFFFFFFF:
|
||||
return b"\xfe" + n.to_bytes(4, "little")
|
||||
return b"\xff" + n.to_bytes(8, "little")
|
||||
|
||||
|
||||
def apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
|
||||
if len(data) > 255:
|
||||
raise RuntimeError(f"APDU data too long: {len(data)}")
|
||||
return bytes([CLA, ins, p1, p2, len(data)]) + data
|
||||
|
||||
|
||||
def exchange(t, apdu_bytes: bytes, timeout_ms: int = 120000) -> bytes:
|
||||
"""Same convention as scripts/send-apdu: transport returns data || sw."""
|
||||
print(f"-> {apdu_bytes.hex().upper()}", file=sys.stderr)
|
||||
response = t.exchange(apdu_bytes, timeout=timeout_ms)
|
||||
if len(response) < 2:
|
||||
raise RuntimeError(f"short response: {response.hex()}")
|
||||
sw = int.from_bytes(response[-2:], "big")
|
||||
data = bytes(response[:-2])
|
||||
print(f"<- sw={sw:04x} data={data.hex().upper()} ({len(data)} bytes)", file=sys.stderr)
|
||||
if sw != 0x9000:
|
||||
raise RuntimeError(f"APDU failed: sw={sw:04x}")
|
||||
return data
|
||||
|
||||
|
||||
def get_compressed_pubkey(t, path_bytes: bytes) -> bytes:
|
||||
"""GET_PUBKEY (no display): pk_len(1)||pk||cc_len(1)||cc||addr_len(1)||addr."""
|
||||
data = exchange(t, apdu(INS_GET_PUBKEY, 0, 0, path_bytes))
|
||||
return data[1 : 1 + data[0]]
|
||||
|
||||
|
||||
def parse_input(s: str):
|
||||
"""`<txid_hex>:<vout>:<value_dollarydoos>`."""
|
||||
txid_str, vout_str, value_str = s.split(":")
|
||||
txid = bytes.fromhex(txid_str)
|
||||
if len(txid) != 32:
|
||||
raise ValueError(f"input txid must be 32 bytes, got {len(txid)}")
|
||||
return txid, int(vout_str), int(value_str)
|
||||
|
||||
|
||||
def parse_output(s: str, hrp: str) -> bytes:
|
||||
"""`<bech32_addr>:<value>` -> value(8 LE)||ver(1)||len(1)||hash||cov(0x00)||varint(0)."""
|
||||
addr, amount_str = s.rsplit(":", 1)
|
||||
got_hrp, data = bech32.bech32_decode(addr)
|
||||
if got_hrp != hrp:
|
||||
raise ValueError(f"address HRP {got_hrp!r} != expected {hrp!r}")
|
||||
if not data:
|
||||
raise ValueError(f"invalid bech32 address: {addr}")
|
||||
witver = data[0]
|
||||
program = bech32.convertbits(data[1:], 5, 8, False)
|
||||
if program is None:
|
||||
raise ValueError(f"invalid bech32 program: {addr}")
|
||||
return (
|
||||
int(amount_str).to_bytes(8, "little")
|
||||
+ bytes([witver, len(program)])
|
||||
+ bytes(program)
|
||||
+ b"\x00" # covenant kind NONE
|
||||
+ encode_varint(0) # covenant item count
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("--path", required=True, help="BIP-32 path, e.g. 44'/5353'/0'/0/0")
|
||||
ap.add_argument("--input", action="append", required=True,
|
||||
help="`<txid>:<vout>:<value>` (repeat for multiple inputs)")
|
||||
ap.add_argument("--output", action="append", default=[],
|
||||
help="`<bech32_addr>:<value>` (repeat for multiple outputs)")
|
||||
ap.add_argument("--tx-version", type=int, default=0)
|
||||
ap.add_argument("--locktime", type=int, default=0)
|
||||
ap.add_argument("--sequence", type=lambda s: int(s, 0), default=0xFFFFFFFF)
|
||||
ap.add_argument("--sighash-type", type=lambda s: int(s, 0), default=0x01)
|
||||
ap.add_argument("--network", choices=list(HRP), default="main")
|
||||
args = ap.parse_args()
|
||||
|
||||
hrp = HRP[args.network]
|
||||
path_bytes = parse_path(args.path)
|
||||
inputs = [parse_input(i) for i in args.input]
|
||||
outputs = [parse_output(o, hrp) for o in args.output]
|
||||
|
||||
t = open_device()
|
||||
try:
|
||||
pubkey = get_compressed_pubkey(t, path_bytes)
|
||||
print(f"# compressed pubkey: {pubkey.hex().upper()}", file=sys.stderr)
|
||||
|
||||
# BEGIN
|
||||
exchange(t, apdu(INS_SIGN_TX, P1_BEGIN, 0,
|
||||
args.tx_version.to_bytes(4, "little")
|
||||
+ encode_varint(len(inputs))
|
||||
+ encode_varint(len(outputs))
|
||||
+ args.locktime.to_bytes(4, "little")))
|
||||
|
||||
# ADD_INPUT (one APDU each)
|
||||
for txid, vout, value in inputs:
|
||||
exchange(t, apdu(INS_SIGN_TX, P1_ADD_INPUT, 0,
|
||||
txid
|
||||
+ vout.to_bytes(4, "little")
|
||||
+ args.sequence.to_bytes(4, "little")
|
||||
+ value.to_bytes(8, "little")
|
||||
+ bytes([args.sighash_type])
|
||||
+ path_bytes
|
||||
+ bytes([SCRIPT_KIND_P2WPKH])))
|
||||
|
||||
# ADD_OUTPUT (chunked; P2 = MORE until the last chunk of each output)
|
||||
for payload in outputs:
|
||||
chunks = [payload[i:i + MAX_DATA] for i in range(0, len(payload), MAX_DATA)]
|
||||
for i, c in enumerate(chunks):
|
||||
p2 = P2_LAST if i == len(chunks) - 1 else P2_MORE
|
||||
exchange(t, apdu(INS_SIGN_TX, P1_ADD_OUTPUT, p2, c))
|
||||
|
||||
# REVIEW (blocks on the user)
|
||||
exchange(t, apdu(INS_SIGN_TX, P1_REVIEW, 0, b""))
|
||||
|
||||
# SIGN_INPUT per input -> sig(64)||sighash(1)||pk_len(1)||pk
|
||||
witnesses = []
|
||||
for idx in range(len(inputs)):
|
||||
r = exchange(t, apdu(INS_SIGN_TX, P1_SIGN_INPUT, 0, idx.to_bytes(4, "big")))
|
||||
if len(r) < 66 or len(r) != 66 + r[65]:
|
||||
raise RuntimeError(f"bad SIGN_INPUT response: {r.hex()}")
|
||||
sig_with_sighash = r[:65]
|
||||
pk = r[66:]
|
||||
print(f"# input {idx} sig: {sig_with_sighash.hex().upper()}", file=sys.stderr)
|
||||
witnesses.append(
|
||||
encode_varint(2)
|
||||
+ encode_varint(len(sig_with_sighash)) + sig_with_sighash
|
||||
+ encode_varint(len(pk)) + pk
|
||||
)
|
||||
|
||||
# version || inputs || outputs || locktime || witnesses (shd Transaction.write)
|
||||
tx = bytearray(args.tx_version.to_bytes(4, "little"))
|
||||
tx += encode_varint(len(inputs))
|
||||
for txid, vout, _ in inputs:
|
||||
tx += txid + vout.to_bytes(4, "little") + args.sequence.to_bytes(4, "little")
|
||||
tx += encode_varint(len(outputs))
|
||||
for payload in outputs:
|
||||
tx += payload
|
||||
tx += args.locktime.to_bytes(4, "little")
|
||||
for w in witnesses:
|
||||
tx += w
|
||||
|
||||
print(tx.hex())
|
||||
return 0
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
43
scripts/test
Executable file
43
scripts/test
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the ragger functional tests against speculos, in Docker.
|
||||
#
|
||||
# Speculos does not run natively on macOS, so both it and ragger live in a
|
||||
# container built from Ledger's dev-tools image (see tests/Dockerfile). The
|
||||
# image is multi-arch, so this runs natively on Apple silicon.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test # stax
|
||||
# ./scripts/test flex # another device
|
||||
# ./scripts/test stax --golden_run # regenerate snapshots
|
||||
# ./scripts/test stax -k policy -v # any extra pytest args
|
||||
#
|
||||
# Regenerating snapshots is a deliberate act: they are the record of what the
|
||||
# review screen shows, which is the thing a hardware wallet is for. Look at the
|
||||
# diff before committing one.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEVICE="${1:-stax}"
|
||||
[[ $# -gt 0 ]] && shift
|
||||
|
||||
# ragger's device names differ from the build target directory names.
|
||||
case "$DEVICE" in
|
||||
nanosp) TARGET=nanosplus ;;
|
||||
*) TARGET="$DEVICE" ;;
|
||||
esac
|
||||
|
||||
IMAGE=ledger-hns-test
|
||||
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "building $IMAGE (first run only)..."
|
||||
docker build -t "$IMAGE" -f tests/Dockerfile tests/
|
||||
fi
|
||||
|
||||
if [[ ! -f "target/$TARGET/release/app-handshake" ]]; then
|
||||
echo "app not built for $TARGET, building..."
|
||||
./scripts/build "$TARGET"
|
||||
fi
|
||||
|
||||
exec docker run --rm -v "$PWD":/app -w /app "$IMAGE" \
|
||||
pytest tests/standalone --tb=short -v --device "$DEVICE" "$@"
|
||||
Reference in New Issue
Block a user