mirror of
https://github.com/luxfi/hsm.git
synced 2026-10-30 16:22:43 +00:00
197 lines
6.7 KiB
Go
197 lines
6.7 KiB
Go
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package hsm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// Ledger Nano (S Plus / X / Stax) is a USB-connected hardware wallet.
|
|
// Unlike the Coldcard / Foundation / Keystone / NGRAVE devices in this
|
|
// package, the Ledger is online — it streams APDU commands over USB
|
|
// HID to the device while the host is connected.
|
|
//
|
|
// The native APDU transport requires HID access via a CGO-linked
|
|
// library (libusb / libhidapi) and a per-app "Ledger app" running on
|
|
// the device (Bitcoin app, Ethereum app, …). Embedding that stack in
|
|
// luxfi/hsm would pull a large native dependency tree.
|
|
//
|
|
// Architecture (this implementation):
|
|
//
|
|
// mpcd ─ stdin/stdout ─> ledgerctl ─ HID ─> Ledger Nano
|
|
//
|
|
// We mirror the YubiHSM signer's design: shell out to the Ledger Live
|
|
// CLI / `ledgerctl` so the official Ledger toolchain owns the USB and
|
|
// app-protocol surface. mpcd never touches USB directly. Operators
|
|
// install ledgerctl on the host, plug the device in, unlock it, and
|
|
// open the relevant app before invoking Sign.
|
|
//
|
|
// Operator setup (Linux, see https://github.com/LedgerHQ/app-ethereum):
|
|
//
|
|
// 1. apt-get install ledger-live # ships ledgerctl
|
|
// 2. Install the Ethereum app via Ledger Live ManagerThen
|
|
// open the app on the device.
|
|
// 3. mpcd --hsm-signer=ledger \
|
|
// --hsm-ledger-tool=ledgerctl \
|
|
// --hsm-ledger-app=ethereum
|
|
//
|
|
// The signer asks ledgerctl to sign a hex-encoded payload. The exact
|
|
// command varies by app (eth-sign-tx, btc-sign-psbt, sol-sign-tx). Use
|
|
// LedgerConfig.SignAction to select the right verb for the app on the
|
|
// device.
|
|
|
|
// LedgerConfig configures a Ledger signer.
|
|
type LedgerConfig struct {
|
|
// ToolPath is the path to ledgerctl. Empty defaults to "ledgerctl"
|
|
// on PATH.
|
|
ToolPath string
|
|
|
|
// App identifies the Ledger app to address. Examples:
|
|
// "ethereum" → eth-sign-tx (handles every EVM-compat chain:
|
|
// Lux C-Chain, Hanzo, Zoo, Pars; chain_id is in
|
|
// the EIP-155 RLP payload)
|
|
// "lux" → lux-sign-tx (Lux native P/X chain operations)
|
|
// "bitcoin" → btc-sign-psbt
|
|
// "solana" → sol-sign-tx
|
|
App string
|
|
|
|
// SignAction overrides the verb passed to ledgerctl. Defaults to
|
|
// "{App}-sign-tx" derived from App.
|
|
SignAction string
|
|
|
|
// DerivationPath is the BIP-32 path of the key to sign with.
|
|
// Required. Format "m/44'/60'/0'/0/0" for EVM, "m/44'/9000'/0'/0/0"
|
|
// for Lux native. Construct via github.com/luxfi/ledger.BIP44PathForName
|
|
// rather than formatting the string by hand.
|
|
DerivationPath string
|
|
|
|
// ChainID is the EIP-155 numeric chain id for EVM-compat signing
|
|
// (Lux C-Chain, Hanzo, Zoo, Pars). Optional — if non-zero, it is
|
|
// passed to ledgerctl via --chain-id so the device displays the
|
|
// correct numeric network identifier on the review screen.
|
|
// Ignored when App != "ethereum".
|
|
ChainID uint64
|
|
}
|
|
|
|
// LedgerSigner produces signatures via a Ledger device using ledgerctl.
|
|
type LedgerSigner struct {
|
|
cfg LedgerConfig
|
|
}
|
|
|
|
// NewLedgerSigner validates configuration and returns a signer.
|
|
func NewLedgerSigner(cfg LedgerConfig) (*LedgerSigner, error) {
|
|
if cfg.App == "" {
|
|
return nil, errors.New("hsm/ledger: App required (e.g., ethereum, bitcoin, solana)")
|
|
}
|
|
if cfg.DerivationPath == "" {
|
|
return nil, errors.New("hsm/ledger: DerivationPath required (e.g., m/44'/60'/0'/0/0)")
|
|
}
|
|
if cfg.SignAction == "" {
|
|
cfg.SignAction = cfg.App + "-sign-tx"
|
|
}
|
|
return &LedgerSigner{cfg: cfg}, nil
|
|
}
|
|
|
|
// Provider returns "ledger".
|
|
func (s *LedgerSigner) Provider() string { return "ledger" }
|
|
|
|
// Sign sends the message to ledgerctl on stdin and returns the device
|
|
// signature on stdout. The operator must approve the transaction on
|
|
// the device — the call blocks until ledgerctl returns.
|
|
//
|
|
// keyID is unused (the key is selected by DerivationPath). Preserved
|
|
// for interface symmetry.
|
|
func (s *LedgerSigner) Sign(ctx context.Context, _ string, message []byte) ([]byte, error) {
|
|
tool := s.cfg.ToolPath
|
|
if tool == "" {
|
|
tool = "ledgerctl"
|
|
}
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: %s not found on PATH: %w", tool, err)
|
|
}
|
|
args := []string{
|
|
s.cfg.SignAction,
|
|
"--path", s.cfg.DerivationPath,
|
|
"--input-format", "hex",
|
|
"--output-format", "hex",
|
|
}
|
|
if strings.EqualFold(s.cfg.App, "ethereum") && s.cfg.ChainID != 0 {
|
|
args = append(args, "--chain-id", fmt.Sprintf("%d", s.cfg.ChainID))
|
|
}
|
|
cmd := exec.CommandContext(ctx, tool, args...)
|
|
cmd.Stdin = strings.NewReader(hex.EncodeToString(message))
|
|
cmd.Stderr = os.Stderr
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: %s failed: %w", s.cfg.SignAction, err)
|
|
}
|
|
sigHex := strings.TrimSpace(out.String())
|
|
if sigHex == "" {
|
|
return nil, errors.New("hsm/ledger: empty signature returned")
|
|
}
|
|
sig, err := hex.DecodeString(sigHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: decode signature: %w", err)
|
|
}
|
|
return sig, nil
|
|
}
|
|
|
|
// GetPubKey fetches the compressed secp256k1 public key for the
|
|
// configured derivation path by invoking ledgerctl. Used by the
|
|
// approval-provider verifier to pin the on-device pubkey at enrollment
|
|
// time.
|
|
//
|
|
// The verb varies by app:
|
|
//
|
|
// ethereum → eth-get-pubkey
|
|
// lux → lux-get-pubkey
|
|
// <other> → {App}-get-pubkey
|
|
//
|
|
// Output is hex; we decode and return the raw bytes.
|
|
func (s *LedgerSigner) GetPubKey(ctx context.Context) ([]byte, error) {
|
|
tool := s.cfg.ToolPath
|
|
if tool == "" {
|
|
tool = "ledgerctl"
|
|
}
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: %s not found on PATH: %w", tool, err)
|
|
}
|
|
verb := s.cfg.App + "-get-pubkey"
|
|
cmd := exec.CommandContext(ctx, tool, verb,
|
|
"--path", s.cfg.DerivationPath,
|
|
"--output-format", "hex",
|
|
)
|
|
cmd.Stderr = os.Stderr
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: %s failed: %w", verb, err)
|
|
}
|
|
pkHex := strings.TrimSpace(out.String())
|
|
if pkHex == "" {
|
|
return nil, errors.New("hsm/ledger: empty pubkey returned")
|
|
}
|
|
pk, err := hex.DecodeString(pkHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hsm/ledger: decode pubkey: %w", err)
|
|
}
|
|
return pk, nil
|
|
}
|
|
|
|
// Verify is unsupported on the Ledger Signer. The device exposes no
|
|
// verify primitive — verification is a public-key operation performed
|
|
// by the chain or by a host-side library against a pinned pubkey
|
|
// fetched once at provisioning.
|
|
func (s *LedgerSigner) Verify(_ context.Context, _ string, _, _ []byte) (bool, error) {
|
|
return false, errors.New("hsm/ledger: Verify not supported (verify with chain client or pinned pubkey)")
|
|
}
|