

An ICARAX Tech‑Blog Implementation Guide
The recent $86 M heist showed how a compromised Ledger Signer hardware implant can leak signing keys if the host software does not validate device authenticity or properly isolate the signing flow.
Below is a complete, copy‑and‑paste‑ready starter kit that demonstrates:
⚠️ NOTE – The code works with a real Ledger Nano S/X (or Ledger Stax) running the latest firmware. It will not extract private keys; all signing happens inside the device.
<a name="step-1-prerequisites"></a>
| Item | Why you need it | Version (tested) |
|---|---|---|
| Ledger device (Nano S, Nano X, or Stax) with Ledger Live installed & latest firmware | Provides the Secure Element that performs signing. | Firmware ≥ 2.0.0 |
| Computer (macOS ≥ 10.15, Windows 10 + WSL2, or Linux ≥ Ubuntu 20.04) | Host for the SDKs. | — |
| Python 3.9+ (or Node.js ≥ 18) | Language runtime for the examples. | Python 3.11, Node 20.x |
Package manager – pip (Python) or npm / yarn (JS/TS) | Install SDK dependencies. | — |
| Ethereum testnet RPC (e.g., Infura, Alchemy, or a local Ganache) | To broadcast the signed transaction. | — |
| Git (optional) | To clone the example repo if you prefer. | — |
Security tip: Never expose your mnemonic, seed phrase, or private key to the host machine. The Ledger SDKs keep the secret inside the Secure Element; the host only sees the public address and signed signatures.
<a name="step-2-installation-and-setup"></a>
# 1️⃣ Create a virtual environment (recommended)
python3 -m venv ledger-demo
source ledger-demo/bin/activate # on Windows: ledger-demo\Scripts\activate
# 2️⃣ Install the LedgerBlue SDK (official Python bindings)
pip install --upgrade pip
pip install ledgerblue eth-account web3
# 3️⃣ (Optional) Install dotenv for env‑var loading
pip install python-dotenv
# 1️⃣ Initialise a new npm project
mkdir ledger-demo-js && cd ledger-demo-js
npm init -y
# 2️⃣ Install LedgerJS packages + ethers.js (or web3.js)
npm install @ledgerhq/hw-transport @ledgerhq/hw-transport-node-hid \
@ledgerhq/hw-app-eth ethers dotenv
# 3️⃣ If you prefer TypeScript, add tsconfig
npm install --save-dev typescript @types/node ts-node
npx tsc --init # creates tsconfig.json
Why these packages?
@ledgerhq/hw-transport-node-hidtalks to the device over USB/HID.@ledgerhq/hw-app-ethimplements the Ethereum app (address derivation & transaction signing).ethers.js(orweb3.js) handles Ethereum RPC calls & transaction encoding.
<a name="step-3-basic-implementation"></a>
Below are fully functional snippets that:
m/44'/60'/0'/0/0.Replace placeholders (
<...>) with your own values (see Step 4).
ledger_sign_tx.py)#!/usr/bin/env python3
"""
ledger_sign_tx.py
Demo: Derive address & sign an Ethereum transaction using a Ledger Nano S/X.
Requires: ledgerblue, eth-account, web3, python-dotenv
"""
import os
import sys
from dotenv import load_dotenv
from ledgerblue.comm import getDongle
from ledgerblue.commException import CommException
from eth_account import Account
from eth_account.signers.local import LocalAccount
from web3 import Web3
# ----------------------------------------------------------------------
# Load configuration (see Step 4)
# ----------------------------------------------------------------------
load_dotenv() # reads .env file
INFURA_URL = os.getenv("INFURA_URL")
PRIVATE_KEY_ENV = os.getenv("ETH_PRIVATE_KEY") # ONLY for fallback; NOT used in prod
CHAIN_ID = int(os.getenv("CHAIN_ID", "1")) # 1 = Mainnet, 5 = Goerli, etc.
if not INFURA_URL:
sys.exit("❌ INFURA_URL not set in environment")
# ----------------------------------------------------------------------
# Helper: BIP‑44 path → bytes expected by Ledger
# ----------------------------------------------------------------------
def bip44_path_to_bytes(path: str) -> bytes:
"""Convert '44'/60'/0'/0/0' -> bytearray([0x80+44, 0x80+60, ...])"""
elements = path.split('/')
result = bytearray()
for part in elements:
hardened = part.endswith("'")
num = int(part.rstrip("'"))
if hardened:
num |= 0x80000000
result.extend(num.to_bytes(4, byteorder='big'))
return bytes(result)
# ----------------------------------------------------------------------
# 1️⃣ Connect to Ledger device
# ----------------------------------------------------------------------
try:
dongle = getDongle(True) # True = debug=False (production)
except CommException as e:
sys.exit(f"❌ Unable to open Ledger device: {e}")
# ----------------------------------------------------------------------
# 2️⃣ Derive Ethereum address (BIP‑44 m/44'/60'/0'/0/0)
# ----------------------------------------------------------------------
ETH_PATH = "44'/60'/0'/0/0"
apdu = bytes.fromhex("e0020000") # INS = GET PUBLIC KEY
apdu += len(bip44_path_to_bytes(ETH_PATH)).to_bytes(1, 'big')
apdu += bip44_path_to_bytes(ETH_PATH)
apdu += b"\x00" # no chain code requested
try:
response = dongle.exchange(apdu)
except CommException as e:
sys.exit(f"❌ APDU exchange failed: {e}")
# Response format: [pubKeyLen][pubKeyX||Y][status]
pubKeyLen = response[0]
pubKey = response[1:1+pubKeyLen]
status = response[-2:]
if status != b"\x90\x00":
sys.exit(f"❌ Device returned error status {status.hex()}")
# Derive Ethereum address from uncompressed public key (64 bytes)
# Ethereum address = keccak256(pubKey[1:])[-20:]
from eth_utils import keccak, to_checksum_address
addr = keccak(pubKey[1:])[-20:]
address = to_checksum_address(addr)
print(f"✅ Ledger address: {address}")
# ----------------------------------------------------------------------
# 3️⃣ Build a simple EIP‑1559 transaction
# ----------------------------------------------------------------------
w3 = Web3(Web3.HTTPProvider(INFURA_URL))
if not w3.isConnected():
sys.exit("❌ Cannot connect to Ethereum RPC")
nonce = w3.eth.get_transaction_count(address)
# Fetch latest block for fee suggestion (simplified)
latest_block = w3.eth.get_block('latest')
base_fee = latest_block['baseFeePerGas']
priority_fee = w3.toWei('2', 'gwei') # adjust as needed
max_fee = base_fee + priority_fee * 2
tx = {
'chainId': CHAIN_ID,
'nonce': nonce,
'maxFeePerGas': max_fee,
'maxPriorityFeePerGas': priority_fee,
'to': '0x742d35Cc6634C0532925a3b8D4C0532950532950', # example recipient
'value': w3.toWei('0.01', 'ether'),
'data': b'', # plain ETH transfer
'accessList': [],
'type': 2, # EIP‑1559
}
# ----------------------------------------------------------------------
# 4️⃣ Serialize tx (RLP) for Ledger signing
# ----------------------------------------------------------------------
signed_tx_rlp = Account.sign_transaction(tx, private_key="0x00"*32).rawTransaction
# The above line uses a dummy key just to get the RLP encoding; we will
# replace the signature later with the Ledger's.
# For brevity we rely on eth_account's encode_transaction:
from eth_account._utils.legacy_transactions import encode_transaction
unsigned_rlp = encode_transaction(tx)
print(f"🔹 Unsigned RLP (hex): {unsigned_rlp.hex()}")
# ----------------------------------------------------------------------
# 5️⃣ Send to Ledger for signing (APPROVE on device)
# ----------------------------------------------------------------------
# Ledger Ethereum app expects: INS = SIGN (0x04)
# P1/P2: 0x00/0x00 (no chain code, no extra data)
# Data: RLP of the transaction
ins_sign = 0x04
p1 = p2 = 0x00
apdu = bytes.fromhex(f"e0{ins_sign:02x}{p1:02x}{p2:02x}")
apdu += len(unsigned_rlp).to_bytes(1, 'big')
apdu += unsigned_rlp
try:
response = dongle.exchange(apdu)
except CommException as e:
sys.exit(f"❌ Signing APDU failed: {e}")
# Response format: [V][R][S][status]
if len(response) < 1 + 32 + 32 + 2:
sys.exit("❌ Unexpected response length from device")
status = response[-2:]
if status != b"\x90\x00":
sys.exit(f"❌ Device signing error: {status.hex()}")
v = response[0]
r = int.from_bytes(response[1:1+32], 'big')
s = int.from_bytes(response[1+32:1+64], 'big')
# Ethereum expects v ∈ {27,28} (or chainId*2+35/36)
v += CHAIN_ID * 2 + 8 # adjust for EIP‑1559 (see ledger docs)
# ----------------------------------------------------------------------
# 6️⃣ Assemble signed transaction & broadcast
# ----------------------------------------------------------------------
signed_tx = w3.eth.account.sign_transaction(
tx,
private_key=None, # we provide v/r/s manually
v=v, r=r, s=s
)
# web3.py expects rawTransaction attribute
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
print(f"🚀 Transaction sent! Hash: {w3.toHex(tx_hash)}")
print(f"🔗 View on etherscan: https://etherscan.io/tx/{w3.toHex(tx_hash)}")
How to run
# 1️⃣ Create .env (see Step 4)
cp .env.example .env # then edit
# 2️⃣ Execute
python ledger_sign_tx.py
You will see a prompt on the Ledger screen: “Sign transaction?” – press both buttons to approve.
ledgerSignTx.ts)// ledgerSignTx.ts
// Demo: Derive address & sign an Ethereum transaction using LedgerJS + ethers.
// Requires: @ledgerhq/hw-transport, @ledgerhq/hw-transport-node-hid,
// @ledgerhq/hw-app-eth, ethers, dotenv
// ts-node ledgerSignTs.ts (or compile with tsc)
import 'dotenv/config';
import { ethers } from 'ethers';
import TransportNodeHid from '@ledgerhq/hw-transport-node-hid';
import Eth from '@ledgerhq/hw-app-eth';
// ----------------------------------------------------------------------
// Load configuration (see Step 4)
// ----------------------------------------------------------------------
const INFURA_URL = process.env.INFURA_URL ?? '';
const CHAIN_ID = Number(process.env.CHAIN_ID ?? '1');
if (!INFURA_URL) {
console.error('❌ INFURA_URL not set');
process.exit(1);
}
const provider = new ethers.providers.JsonRpcProvider(INFURA_URL);
const wallet = new ethers.Wallet(ethers.constants.HashZero, provider); // placeholder
// ----------------------------------------------------------------------
// Helper: BIP‑44 path → number array expected by LedgerJS
// ----------------------------------------------------------------------
function parsePath(path: string): number[] {
return path.split('/').map(part => {
const hardened = part.endswith("'");
let num = parseInt(part.replace("'", ""), 10);
if (hardened) num += 0x80000000;
return num;
});
}
// ----------------------------------------------------------------------
// 1️⃣ Connect to Ledger
// ----------------------------------------------------------------------
async function main() {
let transport;
try {
transport = await TransportNodeHid.create();
} catch (e) {
console.error('❌ Unable to open Ledger device:', e);
process.exit(1);
}
const eth = new Eth(transport);
// ------------------------------------------------------------------
// 2️⃣ Get Ethereum address (m/44'/60'/0'/0/0)
// ------------------------------------------------------------------
const ethPath = "44'/60'/0'/0/0";
const { address } = await eth.getAddress(parsePath(ethPath), false /* display */);
console.log(`✅ Ledger address: ${address}`);
// ------------------------------------------------------------------
// 3️⃣ Build a simple EIP‑1559 transaction
// ------------------------------------------------------------------
const nonce = await provider.getTransactionCount(address);
const block = await provider.getBlock('latest');
const baseFee = block.baseFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
const priorityFee = ethers.utils.parseUnits('2', 'gwei');
const maxFee = baseFee.add(priorityFee.mul(2));
const tx = {
chainId: CHAIN_ID,
nonce,
maxFeePerGas: maxFee,
maxPriorityFeePerGas: priorityFee,
to: '0x742d35Cc6634C0532925a3b8D4C0532950532950', // recipient (example)
value: ethers.utils.parseEther('0.01'),
type: 2, // EIP‑1559
accessList: [],
};
// ------------------------------------------------------------------
// 4️⃣ Serialize (RLP) for Ledger signing
// ------------------------------------------------------------------
const signed = await wallet.signTransaction(tx); // only to get RLP encoding
const unsignedRLP = ethers.utils.serializeTransaction(tx);
console.log(`🔹 Unsigned RLP (hex): ${unsignedRLP}`);
// ------------------------------------------------------------------
// 5️⃣ Ask Ledger to sign (user must approve on device)
// ------------------------------------------------------------------
const { v, r, s } = await eth.signTransaction(parsePath(ethPath), unsignedRLP);
// Ledger returns v as 0/1; we need to convert to Ethereum v
const ethV = v + CHAIN_ID * 2 + 8; // EIP‑1559 correction (see Ledger docs)
// ------------------------------------------------------------------
// 6️⃣ Assemble signed tx & broadcast
// ------------------------------------------------------------------
const signedTx = {
...tx,
v: ethV,
r: `0x${r.toString(16).padStart(64, '0')}`,
s: `0x${s.toString(16).padStart(64, '0')}`,
};
const rawTx = ethers.utils.serializeTransaction(signedTx);
const txHash = await provider.sendTransaction(rawTx);
console.log(`🚀 Transaction sent! Hash: ${txHash.hash}`);
console.log(`🔗 View on etherscan: https://etherscan.io/tx/${txHash.hash}`);
await transport.close();
}
main().catch(err => {
console.error('❌ Fatal error:', err);
process.exit(1);
});
How to run
# 1️⃣ Create .env (see Step 4)
cp .env.example .env # then edit
# 2️⃣ Execute with ts-node (or compile first)
npx ts-node ledgerSignTx.ts
# or, after tsc:
# node ledgerSignTx.js
Again, the Ledger will display “Sign transaction?” – approve with both buttons.
<a name="step-4-configuration"></a>
Create a .env file in the project root (never commit this file!).
# .env.example – copy to .env and fill in your own values
INFURA_URL="https://mainnet.infura.io/v3/<YOUR_INFURA_PROJECT_ID>"
# For testnets you can use:
# INFURA_URL="https://goerli.infura.io/v3/<YOUR_INFURA_PROJECT_ID>"
CHAIN_ID="1" # 1 = Mainnet, 5 = Goerli, 137 = Polygon, etc.
# Optional: if you ever need a fallback private key (NOT for prod)
# ETH_PRIVATE_KEY="0xYOUR_PRIVATE_KEY"
| Variable | Description | Example |
|---|---|---|
INFURA_URL | JSON‑RPC endpoint for Ethereum (or any EVM‑compatible chain). | https://mainnet.infura.io/v3/abcd1234... |
CHAIN_ID | EIP‑1559 chain ID – required for replay protection. | 1 (Mainnet) |
ETH_PRIVATE_KEY | Only for local testing without a Ledger; never use in production. | 0x1111... |
Security tip: Add
.envto your.gitignore. Use a secret‑manager (AWS Secrets Manager, HashiCorp Vault, etc.) in production instead of plain files.
<a name="step-5-common-patterns"></a>
| Pattern | When to use | Code snippet (Python) |
|---|---|---|
| Device attestation (verify genuine Ledger) | Before any signing operation – protects against cloned/fake devices. | python\nfrom ledgerblue.commEscap import getDongle\nfrom ledgerblue.commException import CommException\ndef attest():\n dongle = getDongle(True)\n # Ledger returns a certificate chain on GET VERSION (0x00 0x01)\n version_apdu = bytes.fromhex('e001000000')\n try:\n resp = dongle.exchange(version_apdu)\n # resp[-2:] should be 0x90 0x00; the preceding bytes hold the cert.\n # Validate cert against Ledger's root (omitted for brevity).\n except CommException as e:\n raise RuntimeError('Device not genuine')\n |
| Batching multiple signatures | Reduce USB round‑trips when signing many txs (e.g., merkle‑tree signatures). | python\n# Build list of raw RLPs, then loop with a single transport.open()\n |
| Retry on transport errors | USB hiccups are common; retry with exponential back‑off. | python\nimport time\nfor attempt in range(5):\n try:\n return dongle.exchange(apdu)\n except CommException:\n if attempt == 4: raise\n time.sleep(2 ** attempt)\n |
| Separate signing & broadcasting | Allows offline signing (air‑gapped) and later broadcast via a different node. | python\n# Sign → write signedTx.rawTx to QR/file → later: w3.eth.send_raw_transaction\n |
| Using EIP‑712 typed data signing | For meta‑transactions, permit‑style approvals, etc. | python\nfrom eth_account.messages import encode_typed_data\nmsg = encode_typed_data({...})\nsignature = w3.eth.account.sign_message(msg, private_key=None, vrs=(v,r,s))\n |
<a name="step-6-troubleshooting"></a>
| Symptom | Likely cause | Fix |
|---|---|---|
CommException: No device found | Ledger not plugged in, or USB policy blocking access. | • Ensure device is unlocked and the Ethereum app is open.<br>• On Linux, add udev rule: SUBSYSTEM=="usb", ATTR{idVendor}=="2581", MODE="0660", GROUP="plugdev"<br>• On macOS, disable USB security daemon if needed (sudo nvram boot-args="-no_compat_check"). |
Status 0x6a80 (wrong data length) | Incorrect APDU length field (often forgetting to prefix length byte). | Verify you prepend len(data).to_bytes(1,'big') before the payload. |
Status 0x6982 (security status not satisfied) | User didn’t press both buttons on the device, or the Ethereum app is not in “browser support” mode. | • On the Ledger, go to Settings → Ethereum → Contract data → Yes.<br>• Ensure you are on the Ethereum app, not the Bitcoin app. |
Transaction rejected by node with insufficient funds | You used the wrong network (e.g., Mainnet RPC while device is on testnet). | Set CHAIN_ID and RPC URL to match the network you intend to use. |
Invalid transaction after signing | v value not adjusted for EIP‑1559 or chain ID. | Use v = ledger_v + CHAIN_ID * 2 + 8 (see code). |
Device took too long to respond (>2 s) | USB hub latency or power issue. | Plug device directly into a USB 2.0/3.0 port (avoid hubs). Use a short, high‑quality cable. |
Failed to load .env | .env not in working directory or name mismatch. | Run script from project root, or specify path: load_dotenv(dotenv_path='/app/config/.env'). |
<a name="step-7-production-checklist"></a>
| ✅ Item | Why it matters | How to verify |
|---|---|---|
| Device authenticity check | Prevents cloning attacks. | Run attestation flow (GET VERSION + cert validation) before every session. |
| Isolated signing environment | Limits exposure of private material. | Ensure signing code runs in a dedicated service with no network access except to the Ledger USB interface. |
| Secure transport | USB can be sniffed on compromised hosts. | Use hidapi with exclusive access; consider using a USB‑isolator or a dedicated air‑gapped signing station. |
| Minimum required permissions | Ledger Ethereum app must have “Contract data” = Yes for tx signing. | Verify setting on device; document in SOP. |
| Network‑specific chain ID | Replay attacks across chains. | Always set CHAIN_ID from config; never hard‑code. |
| Gas‑price sanity checks | Prevents accidental over‑payment or stuck txs. | Clamp maxFeePerGas to a reasonable multiple of block.baseFeePerGas. |
| Nonce management | Avoids nonce gaps/re‑use. | Persist last used nonce per address (e.g., in a DB) and increment atomically. |
| Idempotent transaction submission | Guarantees exactly‑once processing. | Store transaction hash before broadcast; if submission fails, re‑fetch receipt before retrying. |
| Monitoring & alerts | Detects device disconnects or signing failures. | Export metrics: ledger_sign_attempts_total, ledger_sign_errors_total, ledger_device_connected. |
| Dependency hygiene | Supply‑chain attacks on SDKs. | Pin exact versions (ledgerblue==0.1.22, @ledgerhq/hw-app-eth@6.27.1); run npm audit / pip check regularly. |
| Backup & recovery plan | If the Ledger is lost, funds must be recoverable via seed. | Store the 24‑word recovery phrase offline, in a geographically separate safe. |
| Logging without secrets | Prevents leakage of signatures or addresses. | Never log v, r, s, or raw transaction bytes; only log hashes. |
| Regular firmware updates | Protects against known device vulnerabilities. | Subscribe to Ledger security bulletins; enforce firmware ≥ latest stable via CI check. |
| Testnet validation | Catch bugs before touching Mainnet. | Deploy identical code to a testnet (Goerli, Sepolia) and verify end‑to‑end flow. |
| Incident response playbook | Fast mitigation if a device is compromised. | Define steps: revoke address, move funds to new address, rotate keys, forensic USB log review. |
You now have:
.env (never commit secrets).Remember: The core security guarantee of a Ledger device is that the private key never leaves the Secure Element. Your host software’s job is to authenticate the device, prepare the transaction correctly, and handle errors gracefully. Follow the checklist, keep dependencies up‑to‑date, and you’ll significantly reduce the risk of a repeat of the $86 M heist.
Happy—and safe—coding! 🚀
Source: Security Week AI
Follow ICARAX for more AI insights and tutorials.
