Skill: roasbeef/lightning-security-module (version 1.0.0)

--- name: lightning-security-module description: Set up an lnd remote signer container that holds private keys separately from the agent. Exports a credentials bundle (accounts JSON, TLS cert, admin macaroon) for watch-only litd nodes. Container-first with Docker, native fallback. Use when firewalling private key material from AI agents. --- # Lightning Security Module (Remote Signer) Set up an lnd remote signer container that holds private keys on a separate, secured machine. The signer never routes payments or opens channels — it only holds keys and signs when asked by a watch-only litd node. ## Architecture ``` Agent Machine Signer Machine (secure) ┌─────────────────┐ ┌─────────────────────┐ │ litd (watch-only)│◄──gRPC───►│ lnd (signer) │ │ - neutrino │ │ - holds seed │ │ - manages chans │ │ - signs commitments │ │ - routes pmts │ │ - signs on-chain txs │ │ - NO key material│ │ - no p2p networking │ └─────────────────┘ └─────────────────────┘ ``` The watch-only node handles all networking and channel management. The signer node holds the seed and performs cryptographic signing. Even if the agent machine is fully compromised, the attacker cannot extract private keys. See [references/architecture.md](references/architecture.md) for the full architecture explainer. ## Quick Start (Container — Recommended) ### On the Signer Machine ```bash # 1. Install lnd signer image skills/lightning-security-module/scripts/install.sh # 2. Start signer container skills/lightning-security-module/scripts/start-signer.sh # 3. Set up signer wallet and export credentials skills/lightning-security-module/scripts/setup-signer.sh # 4. Copy the credentials bundle to the agent machine # The setup script prints the bundle path and base64 string. ``` ### On the Agent Machine ```bash # 5. Import credentials bundle skills/lnd/scripts/import-credentials.sh --bundle <credentials-bundle> # 6. Start litd in watch-only mode skills/lnd/scripts/start-lnd.sh --watchonly # 7. Create watch-only wallet skills/lnd/scripts/create-wallet.sh # 8. Check status skills/lnd/scripts/lncli.sh getinfo ``` ### Two-Container Local Setup For testing both on the same machine: ```bash # Start litd + signer together skills/lnd/scripts/start-lnd.sh --watchonly # Set up signer wallet skills/lightning-security-module/scripts/setup-signer.sh --container litd-signer # Import credentials and create watch-only wallet skills/lnd/scripts/import-credentials.sh --bundle ~/.lnget/signer/credentials-bundle skills/lnd/scripts/create-wallet.sh --container litd ``` ## Installation Default: pulls the lnd Docker image for the signer. ```bash skills/lightning-security-module/scripts/install.sh ``` This pulls `lightninglabs/lnd:v0.20.0-beta` from Docker Hub. The signer only needs plain lnd (not litd) since it only holds keys and signs. ### Build from Source (Fallback) ```bash skills/lightning-security-module/scripts/install.sh --source ``` ## Native Mode For running the signer without Docker: ```bash # Set up signer natively skills/lightning-security-module/scripts/setup-signer.sh --native # Start signer natively skills/lightning-security-module/scripts/start-signer.sh --native # Stop signer natively skills/lightning-security-module/scripts/stop-signer.sh --native ``` ## Remote Nodes Export credentials from a remote signer: ```bash skills/lightning-security-module/scripts/export-credentials.sh \ --rpcserver signer-host:10012 \ --tlscertpath ~/signer-tls.cert \ --macaroonpath ~/signer-admin.macaroon ``` ## Credential Bundle Format The exported bundle (`~/.lnget/signer/credentials-bundle/`) contains: | File | Purpose | |------|---------| | `accounts.json` | Account xpubs for watch-only wallet import | | `tls.cert` | Signer's TLS certificate for authenticated gRPC | | `admin.macaroon` | Signer's admin macaroon for RPC authentication | The bundle is also available as a single base64-encoded tar.gz file (`credentials-bundle.tar.gz.b64`) for easy copy-paste transfer between machines. ## Scripts | Script | Purpose | |--------|---------| | `install.sh` | Pull lnd signer image (or build from source) | | `docker-start.sh` | Start signer container | | `docker-stop.sh` | Stop signer container | | `setup-signer.sh` | Create signer wallet and export credentials | | `start-signer.sh` | Start signer (delegates to Docker by default) | | `stop-signer.sh` | Stop signer (delegates to Docker by default) | | `export-credentials.sh` | Re-export credentials from running signer | ## Managing the Signer ### Start ```bash # Docker (default) skills/lightning-security-module/scripts/start-signer.sh # With network override skills/lightning-security-module/scripts/start-signer.sh --network mainnet ``` ### Stop ```bash # Docker stop (preserve data) skills/lightning-security-module/scripts/stop-signer.sh # Docker stop + remove volumes skills/lightning-security-module/scripts/stop-signer.sh --clean ``` ### Re-export Credentials If TLS certificates or macaroons have been regenerated: ```bash skills/lightning-security-module/scripts/export-credentials.sh ``` ## Configuration ### Container Config The signer compose template is at `skills/lightning-security-module/templates/docker-compose-signer.yml`. Config is passed via command-line arguments. ### Native Config The native signer config template is at `skills/lightning-security-module/templates/signer-lnd.conf.template`. Key differences from a standard lnd node: - **No P2P listening** (`--listen=`) — signer doesn't route - **RPC on 0.0.0.0:10012** — accepts connections from watch-only node - **REST on localhost:10013** — local only, for wallet creation - **TLS extra IP 0.0.0.0** — watch-only on a different machine can connect - **No autopilot, no routing fees** — signer is signing-only ## Security Model **What stays on the signer:** - 24-word seed mnemonic - All private keys (funding, revocation, HTLC) - Wallet database with key material **What gets exported:** - Account xpubs (public keys only — cannot spend) - TLS certificate (for authenticated connection) - Admin macaroon (for RPC auth — scope down for production) **Threat model:** - Compromised agent machine cannot sign transactions or extract keys - Attacker with agent access can see balances and channel state but not spend - Signer machine should have minimal attack surface **Production hardening:** - Replace admin macaroon with a signer-only macaroon (see `macaroon-bakery`) - Restrict signer RPC to specific IP addresses via firewall - Run signer on dedicated hardware or a hardened VM - Use Lightning Node Connect (LNC) via `lightning-mcp-server` for read-only agent access ## Macaroon Bakery for Signer For production, bake a signing-only macaroon: ```bash skills/macaroon-bakery/scripts/bake.sh --role signer-only \ --container litd-signer --rpc-port 10012 ``` Then re-export the credentials bundle with the scoped macaroon. ## Container & Ports | Container | Purpose | Ports | |-----------|---------|-------| | `litd-signer` | Remote signer (lnd) | 10012, 10013 | | Port | Service | Interface | Description | |-------|---------|-----------|-------------| | 10012 | gRPC | 0.0.0.0 | Signer RPC (watch-only connects here) | | 10013 | REST | 0.0.0.0 | REST for wallet creation | ## File Locations | Path | Purpose | |------|---------| | `~/.lnget/signer/wallet-password.txt` | Signer wallet passphrase (0600) | | `~/.lnget/signer/seed.txt` | Signer seed mnemonic (0600) | | `~/.lnget/signer/credentials-bundle/` | Exported credentials | | `~/.lnget/signer/signer-lnd.conf` | Signer config (native mode) | | `versions.env` | Pinned container image versions |

path: templates/signer-lnd-example.toml
content: # LND Configuration for Remote Signer
#
# Minimal config: holds keys, signs transactions, no routing.
# Flat format (no section headers) to avoid go-flags double-prefixing.
# Used by both docker-compose-signer.yml and docker-compose-watchonly.yml.

# Node alias (not visible on network — signer doesn't peer).
alias=lnget-signer

# Log level: trace, debug, info, warn, error, critical.
debuglevel=info

# No p2p listening — signer does not route or open channels.
listen=

# RPC on all interfaces so watch-only node can connect.
rpclisten=0.0.0.0:10012

# REST on all interfaces for Docker port mapping.
restlisten=0.0.0.0:10013

# Auto-unlock wallet on startup using stored passphrase.
wallet-unlock-password-file=/root/.lnd/wallet-password.txt
wallet-unlock-allow-create=true

# TLS: allow connections from any IP (watch-only on different machine).
tlsextraip=0.0.0.0

# TLS: include Docker service and container names so the watch-only node
# can verify the signer's cert when connecting by hostname.
tlsextradomain=signer
tlsextradomain=litd-signer

# Maximum pending channels (none — signer doesn't manage channels).
maxpendingchannels=0

# Activate Bitcoin.
bitcoin.active=true

# Network: mainnet, testnet, signet, regtest.
# Override via NETWORK env var or --network flag.
bitcoin.testnet=true

# Use neutrino light client (signer needs chain awareness for signing).
bitcoin.node=neutrino

# Peers for neutrino block header sync.
neutrino.addpeer=btcd0.lightning.computer
neutrino.addpeer=mainnet1-btcd.zaphq.io
neutrino.addpeer=mainnet2-btcd.zaphq.io

# Fee estimation URL (required for neutrino on mainnet).
# Testnet: https://nodes.lightning.computer/fees/v1/btctestnet-fee-estimates.json
fee.url=https://nodes.lightning.computer/fees/v1/btc-fee-estimates.json

# Maximum peers for neutrino.
neutrino.maxpeers=8

# Persist compact filters to disk for faster restarts.
neutrino.persistfilters=true

# Use SQLite for all databases.
db.backend=sqlite

# Autopilot disabled — signer does not manage channels.
autopilot.active=false

# Tor disabled by default.
tor.active=false


path: templates/docker-compose-signer.yml
content: # Standalone remote signer container using lnd.
#
# The signer holds the wallet seed and signs transactions on behalf of a
# watch-only litd node. It does not route payments or open channels.
#
# All runtime configuration lives in the mounted config file
# (signer-lnd.conf.template by default). Use docker-start.sh to generate
# a config with custom network or debug level.
#
# Usage:
#   skills/lightning-security-module/scripts/docker-start.sh   # Recommended
#   docker compose -f docker-compose-signer.yml up -d
#
# Environment variables (with defaults):
#   LND_VERSION — lnd image tag (default: from versions.env)
#   LND_IMAGE   — lnd image name (default: from versions.env)
#   SIGNER_CONF_PATH — path to generated signer config

services:
  signer:
    image: ${LND_IMAGE:-lightninglabs/lnd}:${LND_VERSION:-v0.20.0-beta}
    container_name: litd-signer
    restart: unless-stopped
    entrypoint: ["/bin/sh", "-c", "touch /root/.lnd/wallet-password.txt && cp /tmp/lnd.conf /root/.lnd/lnd.conf && exec lnd"]
    ports:
      # RPC for watch-only node connections.
      - "${SIGNER_RPC_PORT:-10012}:10012"
      # REST for wallet creation and management.
      - "${SIGNER_REST_PORT:-10013}:10013"
    volumes:
      - signer-data:/root/.lnd
      - ${SIGNER_CONF_PATH:-./signer-lnd.conf.template}:/tmp/lnd.conf:ro

volumes:
  signer-data:


path: scripts/docker-stop.sh
content: #!/usr/bin/env bash
# Stop the remote signer container.
#
# Usage:
#   docker-stop.sh                  # Stop (preserve data)
#   docker-stop.sh --clean          # Stop and remove volumes

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../templates"
CLEAN=false

# Parse arguments.
while [[ $# -gt 0 ]]; do
    case $1 in
        --clean|-v)
            CLEAN=true
            shift
            ;;
        -h|--help)
            echo "Usage: docker-stop.sh [options]"
            echo ""
            echo "Stop the remote signer container."
            echo ""
            echo "Options:"
            echo "  --clean, -v  Remove volumes (clean state)"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Check if the signer container is running.
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'litd-signer'; then
    echo "Signer container is not running."
    exit 0
fi

cd "$TEMPLATE_DIR"

echo "Stopping signer container..."

if [ "$CLEAN" = true ]; then
    docker compose -f docker-compose-signer.yml down -v
    echo "Stopped and removed volumes."
else
    docker compose -f docker-compose-signer.yml down
    echo "Stopped (volumes preserved)."
fi


path: scripts/install.sh
content: #!/usr/bin/env bash
# Install lnd for the remote signer machine.
#
# Default: pulls the pre-built Docker image (fast, no Go required).
# Fallback: --source builds lnd from source (requires Go 1.21+).
#
# The signer only needs lnd (not litd) since it only performs signing
# operations via gRPC — no loop, pool, tapd, or UI needed.
#
# Usage:
#   install.sh                              # Docker pull (default)
#   install.sh --version v0.20.0-beta       # Specific version
#   install.sh --source                     # Build from source

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSIONS_FILE="$SCRIPT_DIR/../../../versions.env"

# Source pinned versions.
if [ -f "$VERSIONS_FILE" ]; then
    source "$VERSIONS_FILE"
fi

VERSION=""
SOURCE=false
BUILD_TAGS="signrpc walletrpc chainrpc invoicesrpc routerrpc peersrpc kvdb_sqlite neutrinorpc"

# Parse arguments.
while [[ $# -gt 0 ]]; do
    case $1 in
        --version)
            VERSION="$2"
            shift 2
            ;;
        --source)
            SOURCE=true
            shift
            ;;
        --tags)
            BUILD_TAGS="$2"
            shift 2
            ;;
        -h|--help)
            echo "Usage: install.sh [options]"
            echo ""
            echo "Install lnd for the remote signer."
            echo ""
            echo "Default: pulls the pre-built Docker image."
            echo ""
            echo "Options:"
            echo "  --version VERSION  Image tag or git tag (default: from versions.env)"
            echo "  --source           Build lnd from source instead of pulling Docker image"
            echo "  --tags TAGS        Build tags for --source mode"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

if [ "$SOURCE" = true ]; then
    # --- Source build mode: clone and build lnd from source ---
    echo "=== Installing lnd (signer) from source ==="
    echo ""

    # Verify Go is installed.
    if ! command -v go &>/dev/null; then
        echo "Error: Go is not installed." >&2
        echo "Install Go from https://go.dev/dl/" >&2
        exit 1
    fi

    GO_VERSION=$(go version | grep -oE 'go[0-9]+\.[0-9]+' | head -1)
    echo "Go version: $GO_VERSION"
    echo "Build tags: $BUILD_TAGS"
    echo ""

    # Use LND_VERSION from versions.env if no --version given.
    SOURCE_VERSION="${VERSION:-${LND_VERSION:-}}"

    # Clone lnd into a temp directory and build from source.
    TMPDIR=$(mktemp -d)
    trap "rm -rf $TMPDIR" EXIT

    echo "Cloning lnd..."
    git clone --quiet https://github.com/lightningnetwork/lnd.git "$TMPDIR/lnd"

    cd "$TMPDIR/lnd"

    # Checkout specific version if requested, otherwise use latest tag.
    if [ -n "$SOURCE_VERSION" ]; then
        echo "Checking out $SOURCE_VERSION..."
        git checkout --quiet "$SOURCE_VERSION"
    else
        LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
        if [ -n "$LATEST_TAG" ]; then
            echo "Using latest tag: $LATEST_TAG"
            git checkout --quiet "$LATEST_TAG"
        else
            echo "Using HEAD (no tags found)."
        fi
    fi
    echo ""

    GOBIN=$(go env GOPATH)/bin

    # Build lnd.
    echo "Building lnd..."
    go build -tags "$BUILD_TAGS" -o "$GOBIN/lnd" ./cmd/lnd
    echo "Done."

    # Build lncli.
    echo "Building lncli..."
    go build -tags "$BUILD_TAGS" -o "$GOBIN/lncli" ./cmd/lncli
    echo "Done."
    echo ""

    # Verify installation.
    if command -v lnd &>/dev/null; then
        echo "lnd installed: $(which lnd)"
        lnd --version 2>/dev/null || true
    else
        echo "Warning: lnd not found on PATH." >&2
        echo "Ensure \$GOPATH/bin is in your PATH." >&2
        echo "  export PATH=\$PATH:\$(go env GOPATH)/bin" >&2
    fi

    if command -v lncli &>/dev/null; then
        echo "lncli installed: $(which lncli)"
    else
        echo "Warning: lncli not found on PATH." >&2
    fi

    echo ""
    echo "Source installation complete."
    echo ""
    echo "Next steps:"
    echo "  1. Set up signer: skills/lightning-security-module/scripts/setup-signer.sh"

else
    # --- Docker mode (default): pull pre-built lnd image ---
    echo "=== Installing lnd (signer) via Docker ==="
    echo ""

    # Verify Docker is available.
    if ! command -v docker &>/dev/null; then
        echo "Error: Docker is not installed." >&2
        echo "Install Docker from https://docs.docker.com/get-docker/" >&2
        echo "" >&2
        echo "Or use --source to build from source (requires Go 1.21+)." >&2
        exit 1
    fi

    IMAGE="${LND_IMAGE:-lightninglabs/lnd}"
    TAG="${VERSION:-${LND_VERSION:-v0.20.0-beta}}"

    echo "Image: $IMAGE:$TAG"
    echo ""

    # Pull the image.
    echo "Pulling image..."
    docker pull "$IMAGE:$TAG"
    echo ""

    # Verify the image works.
    echo "Verifying installation..."
    docker run --rm "$IMAGE:$TAG" lnd --version 2>/dev/null || true
    echo ""

    echo "Installation complete."
    echo ""
    echo "Next steps:"
    echo "  1. Start signer:  skills/lightning-security-module/scripts/docker-start.sh"
    echo "  2. Set up signer: skills/lightning-security-module/scripts/setup-signer.sh"
fi


path: scripts/start-signer.sh
content: #!/usr/bin/env bash
# Start the remote signer lnd node — delegates to Docker by default.
#
# Usage:
#   start-signer.sh                          # Docker (default)
#   start-signer.sh --network mainnet        # Mainnet
#   start-signer.sh --native                 # Native lnd signer
#   start-signer.sh --native --foreground    # Native, foreground

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NATIVE=false

# Check for --native flag before parsing other args.
PASS_ARGS=()
for arg in "$@"; do
    if [ "$arg" = "--native" ]; then
        NATIVE=true
    else
        PASS_ARGS+=("$arg")
    fi
done

# If not native mode, delegate to docker-start.sh.
if [ "$NATIVE" = false ]; then
    if command -v docker &>/dev/null; then
        exec "$SCRIPT_DIR/docker-start.sh" "${PASS_ARGS[@]}"
    else
        echo "Docker not available. Falling back to native mode." >&2
        echo "Install Docker or use --native explicitly." >&2
        echo ""
        NATIVE=true
    fi
fi

# --- Native mode: original signer startup logic ---

LNGET_SIGNER_DIR="${LNGET_SIGNER_DIR:-$HOME/.lnget/signer}"
LND_SIGNER_DIR="${LND_SIGNER_DIR:-$HOME/.lnd-signer}"
NETWORK="testnet"
FOREGROUND=false
EXTRA_ARGS=""
RPC_PORT=10012
CONF_FILE="$LNGET_SIGNER_DIR/signer-lnd.conf"

# Parse arguments.
set -- "${PASS_ARGS[@]}"
while [[ $# -gt 0 ]]; do
    case $1 in
        --network)
            NETWORK="$2"
            shift 2
            ;;
        --lnddir)
            LND_SIGNER_DIR="$2"
            shift 2
            ;;
        --foreground)
            FOREGROUND=true
            shift
            ;;
        --rpc-port)
            RPC_PORT="$2"
            shift 2
            ;;
        --extra-args)
            EXTRA_ARGS="$2"
            shift 2
            ;;
        -h|--help)
            echo "Usage: start-signer.sh [options]"
            echo ""
            echo "Start the remote signer lnd node."
            echo ""
            echo "Docker options (default):"
            echo "  --network NET     Override network (testnet, mainnet, signet)"
            echo "  --foreground      Run in foreground (show logs)"
            echo ""
            echo "Native options (--native):"
            echo "  --native             Run lnd as a local process"
            echo "  --lnddir DIR         Signer lnd data directory (default: ~/.lnd-signer)"
            echo "  --rpc-port PORT      Signer RPC port (default: 10012)"
            echo "  --extra-args ARGS    Additional lnd arguments"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Verify lnd is installed.
if ! command -v lnd &>/dev/null; then
    echo "Error: lnd not found. Run install.sh first." >&2
    exit 1
fi

# Check if signer is already running by checking the RPC port.
if curl -sk "https://localhost:$RPC_PORT/v1/state" &>/dev/null 2>&1; then
    echo "Signer lnd is already running on port $RPC_PORT."
    echo "Use stop-signer.sh to stop it first."
    exit 1
fi

# Verify config exists.
if [ ! -f "$CONF_FILE" ]; then
    echo "Error: Signer config not found at $CONF_FILE" >&2
    echo "Run setup-signer.sh --native first." >&2
    exit 1
fi

echo "=== Starting Signer LND ==="
echo "Network:  $NETWORK"
echo "Data dir: $LND_SIGNER_DIR"
echo "Config:   $CONF_FILE"
echo "RPC port: $RPC_PORT"
echo ""

LOG_FILE="$LNGET_SIGNER_DIR/signer-lnd.log"

if [ "$FOREGROUND" = true ]; then
    exec lnd \
        --lnddir="$LND_SIGNER_DIR" \
        --configfile="$CONF_FILE" \
        $EXTRA_ARGS
else
    nohup lnd \
        --lnddir="$LND_SIGNER_DIR" \
        --configfile="$CONF_FILE" \
        $EXTRA_ARGS \
        > "$LOG_FILE" 2>&1 &
    SIGNER_PID=$!
    echo "Signer lnd started in background (PID: $SIGNER_PID)"
    echo "Log file: $LOG_FILE"
    echo ""

    # Wait briefly and verify it's running.
    sleep 2
    if kill -0 "$SIGNER_PID" 2>/dev/null; then
        echo "Signer lnd is running."
    else
        echo "Error: signer lnd exited immediately. Check $LOG_FILE" >&2
        tail -20 "$LOG_FILE" 2>/dev/null
        exit 1
    fi

    echo ""
    echo "The signer is ready to accept connections from watch-only nodes."
    echo "Watch-only nodes connect to this machine on port $RPC_PORT."
fi


path: scripts/setup-signer.sh
content: #!/usr/bin/env bash
# Set up a remote signer: create wallet, export credentials bundle.
#
# Container mode (default — auto-detects litd-signer container):
#   setup-signer.sh                              # Auto-detect container
#   setup-signer.sh --container litd-signer      # Explicit container
#
# Native mode:
#   setup-signer.sh --native                     # Local lnd signer
#   setup-signer.sh --native --network mainnet   # Mainnet
#   setup-signer.sh --native --password "mypass" # Custom passphrase
#
# Stores credentials at:
#   ~/.lnget/signer/wallet-password.txt           (mode 0600)
#   ~/.lnget/signer/seed.txt                      (mode 0600)
#   ~/.lnget/signer/credentials-bundle/           (exported credentials)

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LNGET_SIGNER_DIR="${LNGET_SIGNER_DIR:-$HOME/.lnget/signer}"
LND_SIGNER_DIR="${LND_SIGNER_DIR:-$HOME/.lnd-signer}"
NETWORK="testnet"
PASSWORD=""
RPC_PORT=10012
REST_PORT=10013
CONTAINER=""
NATIVE=false

# Parse arguments.
while [[ $# -gt 0 ]]; do
    case $1 in
        --network)
            NETWORK="$2"
            shift 2
            ;;
        --lnddir)
            LND_SIGNER_DIR="$2"
            shift 2
            ;;
        --password)
            PASSWORD="$2"
            shift 2
            ;;
        --rpc-port)
            RPC_PORT="$2"
            shift 2
            ;;
        --rest-port)
            REST_PORT="$2"
            shift 2
            ;;
        --container)
            CONTAINER="$2"
            shift 2
            ;;
        --native)
            NATIVE=true
            shift
            ;;
        -h|--help)
            echo "Usage: setup-signer.sh [options]"
            echo ""
            echo "Set up an lnd remote signer node."
            echo ""
            echo "Connection options:"
            echo "  --container NAME    Set up signer in a Docker container"
            echo "  --native            Set up signer on local lnd process"
            echo ""
            echo "Options:"
            echo "  --network NETWORK   Bitcoin network (default: testnet)"
            echo "  --lnddir DIR        Signer lnd data directory (default: ~/.lnd-signer)"
            echo "  --password PASS     Wallet passphrase (auto-generated if omitted)"
            echo "  --rpc-port PORT     Signer RPC port (default: 10012)"
            echo "  --rest-port PORT    Signer REST port (default: 10013)"
            echo ""
            echo "Container auto-detection: looks for litd-signer container."
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Auto-detect container if not native and no container specified.
if [ "$NATIVE" = false ] && [ -z "$CONTAINER" ]; then
    if command -v docker &>/dev/null; then
        if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'litd-signer'; then
            CONTAINER="litd-signer"
        fi
    fi

    # If no container found, fall back to native.
    if [ -z "$CONTAINER" ]; then
        NATIVE=true
    fi
fi

# Container mode: verify container is running.
if [ -n "$CONTAINER" ]; then
    if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER"; then
        echo "Error: Container '$CONTAINER' is not running." >&2
        echo "Start it with: skills/lightning-security-module/scripts/docker-start.sh" >&2
        exit 1
    fi
fi

echo "=== Remote Signer Setup ==="
echo ""
if [ -n "$CONTAINER" ]; then
    echo "Container:   $CONTAINER"
else
    echo "Mode:        native"
    echo "Signer dir:  $LND_SIGNER_DIR"
fi
echo "Network:     $NETWORK"
echo "Creds dir:   $LNGET_SIGNER_DIR"
echo "RPC port:    $RPC_PORT"
echo "REST port:   $REST_PORT"
echo ""

# Native mode: verify lnd is installed.
if [ "$NATIVE" = true ]; then
    if ! command -v lnd &>/dev/null; then
        echo "Error: lnd not found. Run install.sh first." >&2
        exit 1
    fi
fi

# Create directories with restricted permissions.
mkdir -p "$LNGET_SIGNER_DIR"
chmod 700 "$LNGET_SIGNER_DIR"

if [ "$NATIVE" = true ]; then
    mkdir -p "$LND_SIGNER_DIR"
fi

PASSWORD_FILE="$LNGET_SIGNER_DIR/wallet-password.txt"
SEED_OUTPUT="$LNGET_SIGNER_DIR/seed.txt"
CONF_FILE="$LNGET_SIGNER_DIR/signer-lnd.conf"

# Generate or use provided passphrase.
if [ -n "$PASSWORD" ]; then
    echo "Using provided passphrase."
else
    echo "Generating secure passphrase..."
    [REDACTED_SECRET] rand -base64 32 | tr -d '/+=' | head -c 32)
fi

# Store passphrase with restricted permissions on the host.
echo -n "$PASSWORD" > "$PASSWORD_FILE"
chmod 600 "$PASSWORD_FILE"
echo "Passphrase saved to $PASSWORD_FILE (mode 0600)"
echo ""

# Copy password file into container if applicable.
if [ -n "$CONTAINER" ]; then
    docker cp "$PASSWORD_FILE" "$CONTAINER:/root/.lnd/wallet-password.txt"
    echo "Password file copied into container."
fi

# Source shared REST helpers. REST_HOST defaults to localhost for
# the signer; CONTAINER_PORT defaults to REST_PORT.
REST_HOST="localhost"
source "$SCRIPT_DIR/../../lib/rest.sh"

# For native mode, create config and start lnd temporarily if needed.
if [ "$NATIVE" = true ]; then
    # Create signer config from template.
    TEMPLATE="$SCRIPT_DIR/../templates/signer-lnd.conf.template"
    if [ -f "$TEMPLATE" ]; then
        echo "Creating signer config from template..."
        # Replace network flag — match any existing network variant.
        sed -E "s/bitcoin\.(testnet|mainnet|signet|regtest)=true/bitcoin.$NETWORK=true/g" "$TEMPLATE" > "$CONF_FILE"

        # Replace password file path.
        sed -i.bak "s|wallet-unlock-password-file=.*|wallet-unlock-password-file=$PASSWORD_FILE|g" "$CONF_FILE"

        # Replace port numbers. Native mode binds to localhost, not 0.0.0.0.
        sed -i.bak "s|rpclisten=0.0.0.0:10012|rpclisten=localhost:$RPC_PORT|g" "$CONF_FILE"
        sed -i.bak "s|restlisten=0.0.0.0:10013|restlisten=localhost:$REST_PORT|g" "$CONF_FILE"
        rm -f "$CONF_FILE.bak"
        echo "Config saved to $CONF_FILE"
    else
        echo "Error: Config template not found at $TEMPLATE" >&2
        exit 1
    fi
    echo ""

    # Start signer lnd temporarily for wallet creation if not running.
    if rest_call GET "/v1/state" &>/dev/null; then
        echo "Signer lnd is already running."
    else
        echo "Starting signer lnd temporarily for wallet creation..."
        nohup lnd \
            --lnddir="$LND_SIGNER_DIR" \
            --configfile="$CONF_FILE" \
            > "$LNGET_SIGNER_DIR/signer-setup.log" 2>&1 &
        SIGNER_PID=$!

        echo "Waiting for signer lnd to start (PID: $SIGNER_PID)..."
        for i in {1..30}; do
            if rest_call GET "/v1/state" &>/dev/null; then
                break
            fi
            if ! kill -0 "$SIGNER_PID" 2>/dev/null; then
                echo "Error: signer lnd exited. Check $LNGET_SIGNER_DIR/signer-setup.log" >&2
                exit 1
            fi
            sleep 2
            echo "  Waiting... ($i/30)"
        done
        echo ""
    fi
else
    # Container mode: wait for REST API to be available inside container.
    wait_for_rest "signer REST API"
fi

# Create wallet via REST API.
echo "=== Creating Signer Wallet ==="

# Generate seed.
echo "Generating wallet seed..."
SEED_RESPONSE=$(rest_call GET "/v1/genseed")

MNEMONIC=$(echo "$SEED_RESPONSE" | jq -r '.cipher_seed_mnemonic[]' 2>/dev/null)
if [ -z "$MNEMONIC" ] || [ "$MNEMONIC" = "null" ]; then
    echo "Error: Failed to generate seed." >&2
    echo "Response: $SEED_RESPONSE" >&2
    exit 1
fi

# Store seed with restricted permissions on host.
echo "$MNEMONIC" > "$SEED_OUTPUT"
chmod 600 "$SEED_OUTPUT"
echo "Seed mnemonic saved to $SEED_OUTPUT (mode 0600)"
echo ""

# Initialize wallet with password and seed.
SEED_JSON=$(echo "$MNEMONIC" | jq -R . | jq -s .)
PAYLOAD=$(jq -n \
    --arg pass "$(echo -n "$PASSWORD" | base64)" \
    --argjson seed "$SEED_JSON" \
    '{wallet_password: $pass, cipher_seed_mnemonic: $seed}')

RESPONSE=$(rest_call POST "/v1/initwallet" "$PAYLOAD")

ERROR=$(echo "$RESPONSE" | jq -r '.message // empty' 2>/dev/null)
if [ -n "$ERROR" ]; then
    echo "Error creating wallet: $ERROR" >&2
    exit 1
fi

echo "Signer wallet created successfully!"
echo ""

# Wait for signer to be fully ready (wallet unlocked, RPC available).
echo "Waiting for signer to be fully ready..."
for i in {1..30}; do
    STATE=$(rest_call GET "/v1/state" 2>/dev/null | jq -r '.state // empty' 2>/dev/null)
    if [ "$STATE" = "SERVER_ACTIVE" ]; then
        break
    fi
    sleep 2
    echo "  Waiting for RPC... ($i/30)"
done
echo ""

# Export credentials bundle.
echo "=== Exporting Credentials Bundle ==="
if [ -n "$CONTAINER" ]; then
    "$SCRIPT_DIR/export-credentials.sh" \
        --container "$CONTAINER" \
        --network "$NETWORK" \
        --rpc-port "$RPC_PORT"
else
    "$SCRIPT_DIR/export-credentials.sh" \
        --network "$NETWORK" \
        --lnddir "$LND_SIGNER_DIR" \
        --rpc-port "$RPC_PORT"
fi

echo ""
echo "=== Signer Setup Complete ==="
echo ""
echo "Credential locations:"
echo "  Passphrase: $PASSWORD_FILE"
echo "  Seed:       $SEED_OUTPUT"
if [ "$NATIVE" = true ]; then
    echo "  Config:     $CONF_FILE"
fi
echo ""
echo "IMPORTANT: The seed mnemonic at $SEED_OUTPUT is the master secret."
echo "Back it up securely and restrict access to this machine."
echo ""
echo "Next steps:"
echo "  1. Copy the credentials bundle to your agent machine"
echo "  2. On the agent: skills/lnd/scripts/import-credentials.sh --bundle <path>"
echo "  3. On the agent: skills/lnd/scripts/create-wallet.sh"
if [ -n "$CONTAINER" ]; then
    echo "  4. On the agent: skills/lnd/scripts/docker-start.sh --watchonly"
else
    echo "  4. On the agent: skills/lnd/scripts/start-lnd.sh --native --signer-host <this-ip>:$RPC_PORT"
fi


path: scripts/docker-start.sh
content: #!/usr/bin/env bash
# Start the remote signer container.
#
# Usage:
#   docker-start.sh                          # Default (testnet, background)
#   docker-start.sh --network mainnet        # Mainnet (real coins)
#   docker-start.sh --regtest                # Regtest with bitcoind backend
#   docker-start.sh --foreground             # Run in foreground (show logs)
#   docker-start.sh --build                  # Rebuild before starting
#   docker-start.sh --docker-network NAME    # Join an existing Docker network

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../templates"
VERSIONS_FILE="$SCRIPT_DIR/../../../versions.env"
LIB_DIR="$SCRIPT_DIR/../../lib"

DETACH=true
BUILD=false
CUSTOM_NETWORK=""
DOCKER_NETWORK=""

# Source pinned versions so compose files pick them up as env vars.
if [ -f "$VERSIONS_FILE" ]; then
    source "$VERSIONS_FILE"
    export LND_VERSION LND_IMAGE
fi

# Source config generation functions.
source "$LIB_DIR/config-gen.sh"

# Parse arguments.
while [[ $# -gt 0 ]]; do
    case $1 in
        --network)
            CUSTOM_NETWORK="$2"
            shift 2
            ;;
        --regtest)
            CUSTOM_NETWORK="regtest"
            shift
            ;;
        --docker-network)
            DOCKER_NETWORK="$2"
            shift 2
            ;;
        --build)
            BUILD=true
            shift
            ;;
        --foreground|-f)
            DETACH=false
            shift
            ;;
        -h|--help)
            echo "Usage: docker-start.sh [options]"
            echo ""
            echo "Start the remote signer container."
            echo ""
            echo "Options:"
            echo "  --network NET         Override network (testnet, mainnet, signet, regtest)"
            echo "  --regtest             Shorthand for --network regtest"
            echo "  --docker-network NET  Join an existing Docker network (e.g., regtest bitcoind)"
            echo "  --build               Rebuild images before starting"
            echo "  --foreground, -f      Run in foreground (show logs)"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Override network if specified on command line.
if [ -n "$CUSTOM_NETWORK" ]; then
    NETWORK="$CUSTOM_NETWORK"
fi

EFFECTIVE_NETWORK="${NETWORK:-testnet}"

# --- Generate runtime config from template ---

LNGET_LND_DIR="${LNGET_LND_DIR:-$HOME/.lnget/lnd}"
mkdir -p "$LNGET_LND_DIR"

generate_lnd_config \
    "$TEMPLATE_DIR/signer-lnd.conf.template" \
    "$LNGET_LND_DIR/signer-lnd.conf" \
    "$EFFECTIVE_NETWORK" \
    "${LND_DEBUG:-info}" \
    ""

export SIGNER_CONF_PATH="$LNGET_LND_DIR/signer-lnd.conf"

# --- Start container ---

cd "$TEMPLATE_DIR"

echo "=== Starting signer container ==="
echo "  Config:   $SIGNER_CONF_PATH"
echo "  Network:  $EFFECTIVE_NETWORK"
echo "  Image:    ${LND_IMAGE:-lightninglabs/lnd}:${LND_VERSION:-v0.20.0-beta}"
echo ""

# Build the docker-compose command.
CMD="docker compose -f docker-compose-signer.yml"

if [ "$BUILD" = true ]; then
    CMD="$CMD up --build"
else
    CMD="$CMD up"
fi

if [ "$DETACH" = true ]; then
    CMD="$CMD -d"
fi

echo "Running: $CMD"
eval "$CMD"

# When running on regtest, connect the signer to the regtest Docker network
# so it can reach bitcoind.
if [ "$EFFECTIVE_NETWORK" = "regtest" ] && [ "$DETACH" = true ]; then
    REGTEST_NETWORK="${DOCKER_NETWORK:-$(docker network ls --filter name=regtest --format '{{.Name}}' | head -1)}"
    if [ -n "$REGTEST_NETWORK" ]; then
        echo "Connecting signer to regtest network: $REGTEST_NETWORK"
        docker network connect "$REGTEST_NETWORK" litd-signer 2>/dev/null || true
    else
        echo "Warning: No regtest Docker network found." >&2
        echo "Start regtest bitcoind first, or use --docker-network to specify." >&2
    fi
fi

if [ "$DETACH" = true ]; then
    echo ""
    echo "Signer started in background."
    echo ""
    echo "Check logs:"
    echo "  docker logs -f litd-signer"
    echo ""
    echo "Next: set up the signer wallet (if first run):"
    echo "  skills/lightning-security-module/scripts/setup-signer.sh --container litd-signer"
fi


path: scripts/stop-signer.sh
content: #!/usr/bin/env bash
# Stop the remote signer — delegates to Docker by default.
#
# Usage:
#   stop-signer.sh                          # Docker stop (auto-detect)
#   stop-signer.sh --clean                  # Docker stop + remove volumes
#   stop-signer.sh --native                 # Stop native signer process
#   stop-signer.sh --native --force         # SIGTERM native process
#   stop-signer.sh --container litd-signer  # Explicit container
#   stop-signer.sh --rpcserver remote:10012 --tlscertpath ~/tls.cert --macaroonpath ~/admin.macaroon

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NATIVE=false

# Check for --native flag before parsing other args.
PASS_ARGS=()
for arg in "$@"; do
    if [ "$arg" = "--native" ]; then
        NATIVE=true
    else
        PASS_ARGS+=("$arg")
    fi
done

# If not native mode, delegate to docker-stop.sh.
if [ "$NATIVE" = false ]; then
    # Check if we have a --container arg or docker-specific flags.
    HAS_CONTAINER_ARG=false
    HAS_RPCSERVER=false
    for arg in "${PASS_ARGS[@]}"; do
        if [ "$arg" = "--container" ]; then
            HAS_CONTAINER_ARG=true
        fi
        if [ "$arg" = "--rpcserver" ]; then
            HAS_RPCSERVER=true
        fi
    done

    # If --rpcserver is specified, fall through to native stop logic (remote
    # mode needs lncli).
    if [ "$HAS_RPCSERVER" = true ]; then
        NATIVE=true
    elif [ "$HAS_CONTAINER_ARG" = true ]; then
        # Explicit container — use native stop logic (handles docker exec).
        NATIVE=true
    elif command -v docker &>/dev/null; then
        if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'litd-signer'; then
            exec "$SCRIPT_DIR/docker-stop.sh" "${PASS_ARGS[@]}"
        fi
        # No signer container found, fall through to native.
        NATIVE=true
    else
        NATIVE=true
    fi
fi

# --- Native / container-specific stop logic ---

LND_SIGNER_DIR="${LND_SIGNER_DIR:-}"
NETWORK="${NETWORK:-testnet}"
RPC_PORT=10012
FORCE=false
CONTAINER=""
RPCSERVER=""
TLSCERTPATH=""
MACAROONPATH=""

# Parse arguments.
set -- "${PASS_ARGS[@]}"
while [[ $# -gt 0 ]]; do
    case $1 in
        --force)
            FORCE=true
            shift
            ;;
        --network)
            NETWORK="$2"
            shift 2
            ;;
        --rpc-port)
            RPC_PORT="$2"
            shift 2
            ;;
        --container)
            CONTAINER="$2"
            shift 2
            ;;
        --rpcserver)
            RPCSERVER="$2"
            shift 2
            ;;
        --tlscertpath)
            TLSCERTPATH="$2"
            shift 2
            ;;
        --macaroonpath)
            MACAROONPATH="$2"
            shift 2
            ;;
        --clean|-v)
            # Docker-specific flags; delegate to docker-stop.sh.
            exec "$SCRIPT_DIR/docker-stop.sh" "${PASS_ARGS[@]}"
            ;;
        -h|--help)
            echo "Usage: stop-signer.sh [options]"
            echo ""
            echo "Stop the remote signer."
            echo ""
            echo "Docker options (default):"
            echo "  --clean, -v            Remove volumes (clean state)"
            echo ""
            echo "Native options (--native):"
            echo "  --native               Stop native signer process"
            echo "  --force                Send SIGTERM immediately"
            echo "  --container NAME       Stop signer in a specific container"
            echo "  --rpcserver HOST:PORT  Connect to a remote signer node"
            echo "  --tlscertpath PATH     TLS certificate for remote connection"
            echo "  --macaroonpath PATH    Macaroon for remote authentication"
            echo "  --rpc-port PORT        Signer RPC port (default: 10012)"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Apply default lnddir if not set.
if [ -z "$LND_SIGNER_DIR" ]; then
    if [ -n "$CONTAINER" ]; then
        LND_SIGNER_DIR="/root/.lnd"
    else
        LND_SIGNER_DIR="$HOME/.lnd-signer"
    fi
fi

if [ -n "$CONTAINER" ]; then
    # Docker container mode.
    if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
        echo "Container '$CONTAINER' is not running."
        exit 0
    fi

    echo "Stopping signer lnd in container '$CONTAINER'..."

    if [ "$FORCE" = true ]; then
        docker stop "$CONTAINER"
        echo "Container stopped."
    else
        if docker exec "$CONTAINER" lncli --rpcserver="localhost:$RPC_PORT" \
            --lnddir="$LND_SIGNER_DIR" \
            --network="$NETWORK" \
            stop 2>/dev/null; then
            echo "Graceful shutdown initiated."
        else
            echo "lncli stop failed, stopping container..."
            docker stop "$CONTAINER"
            echo "Container stopped."
        fi
    fi
    exit 0
fi

# Build connection flags for lncli.
CONN_FLAGS=(--network="$NETWORK" --lnddir="$LND_SIGNER_DIR")
if [ -n "$RPCSERVER" ]; then
    CONN_FLAGS+=("--rpcserver=$RPCSERVER")
else
    CONN_FLAGS+=("--rpcserver=localhost:$RPC_PORT")
fi
if [ -n "$TLSCERTPATH" ]; then
    CONN_FLAGS+=("--tlscertpath=$TLSCERTPATH")
fi
if [ -n "$MACAROONPATH" ]; then
    CONN_FLAGS+=("--macaroonpath=$MACAROONPATH")
fi

# Remote mode — stop via lncli only (no PID or port access).
if [ -n "$RPCSERVER" ]; then
    echo "Stopping remote signer at $RPCSERVER..."
    if lncli "${CONN_FLAGS[@]}" stop; then
        echo "Graceful shutdown initiated."
    else
        echo "Error: lncli stop failed for remote signer." >&2
        exit 1
    fi
    exit 0
fi

# Local mode — check if signer is running by probing the RPC port.
if ! curl -sk "https://localhost:$RPC_PORT/v1/state" &>/dev/null 2>&1; then
    echo "Signer lnd is not running (port $RPC_PORT not responding)."
    exit 0
fi

echo "Stopping signer lnd..."

if [ "$FORCE" = true ]; then
    # Find the process listening on the signer RPC port and kill it.
    SIGNER_PID=$(lsof -ti ":$RPC_PORT" 2>/dev/null | head -1 || true)
    if [ -n "$SIGNER_PID" ]; then
        kill "$SIGNER_PID"
        echo "Sent SIGTERM to PID $SIGNER_PID."
    else
        echo "Warning: Could not find process on port $RPC_PORT." >&2
        exit 1
    fi
else
    # Try graceful shutdown via lncli.
    if lncli "${CONN_FLAGS[@]}" stop 2>/dev/null; then
        echo "Graceful shutdown initiated."
    else
        echo "lncli stop failed, finding process on port $RPC_PORT..."
        SIGNER_PID=$(lsof -ti ":$RPC_PORT" 2>/dev/null | head -1 || true)
        if [ -n "$SIGNER_PID" ]; then
            kill "$SIGNER_PID"
            echo "Sent SIGTERM to PID $SIGNER_PID."
        else
            echo "Warning: Could not find process to stop." >&2
            exit 1
        fi
    fi
fi

# Wait for process to exit.
echo "Waiting for signer lnd to exit..."
for i in {1..15}; do
    if ! curl -sk "https://localhost:$RPC_PORT/v1/state" &>/dev/null 2>&1; then
        echo "Signer lnd stopped."
        exit 0
    fi
    sleep 1
done

echo "Warning: signer lnd did not exit within 15 seconds." >&2
echo "Use --force or manually kill the process." >&2
exit 1


path: scripts/export-credentials.sh
content: #!/usr/bin/env bash
# Export credentials bundle from a running signer for watch-only node import.
#
# Container mode (default — auto-detects litd-signer container):
#   export-credentials.sh                             # Auto-detect container
#   export-credentials.sh --container litd-signer     # Explicit container
#
# Native mode:
#   export-credentials.sh --native                    # Local signer
#   export-credentials.sh --native --network testnet  # Testnet
#   export-credentials.sh --native --output /path     # Custom output dir
#
# Remote mode:
#   export-credentials.sh --rpcserver remote:10012 \
#                         --tlscertpath ~/tls.cert \
#                         --macaroonpath ~/admin.macaroon
#
# Produces:
#   ~/.lnget/signer/credentials-bundle/accounts.json
#   ~/.lnget/signer/credentials-bundle/tls.cert
#   ~/.lnget/signer/credentials-bundle/admin.macaroon
#   ~/.lnget/signer/credentials-bundle.tar.gz.b64       (portable base64)

set -e

LNGET_SIGNER_DIR="${LNGET_SIGNER_DIR:-$HOME/.lnget/signer}"
LND_SIGNER_DIR="${LND_SIGNER_DIR:-}"
NETWORK="testnet"
RPC_PORT=10012
OUTPUT_DIR=""
CONTAINER=""
NATIVE=false
RPCSERVER=""
TLSCERTPATH=""
MACAROONPATH=""

# Parse arguments.
while [[ $# -gt 0 ]]; do
    case $1 in
        --network)
            NETWORK="$2"
            shift 2
            ;;
        --lnddir)
            LND_SIGNER_DIR="$2"
            shift 2
            ;;
        --rpc-port)
            RPC_PORT="$2"
            shift 2
            ;;
        --output)
            OUTPUT_DIR="$2"
            shift 2
            ;;
        --container)
            CONTAINER="$2"
            shift 2
            ;;
        --native)
            NATIVE=true
            shift
            ;;
        --rpcserver)
            RPCSERVER="$2"
            shift 2
            ;;
        --tlscertpath)
            TLSCERTPATH="$2"
            shift 2
            ;;
        --macaroonpath)
            MACAROONPATH="$2"
            shift 2
            ;;
        -h|--help)
            echo "Usage: export-credentials.sh [options]"
            echo ""
            echo "Export credentials bundle from a running signer."
            echo ""
            echo "Connection options:"
            echo "  --container NAME       Export from signer in a Docker container"
            echo "  --native               Export from local signer process"
            echo "  --rpcserver HOST:PORT  Connect to a remote signer node"
            echo "  --tlscertpath PATH     TLS certificate for remote connection (also bundled)"
            echo "  --macaroonpath PATH    Macaroon for remote authentication (also bundled)"
            echo ""
            echo "Options:"
            echo "  --network NETWORK      Bitcoin network (default: testnet)"
            echo "  --lnddir DIR           Signer lnd data directory (default: ~/.lnd-signer)"
            echo "  --rpc-port PORT        Signer RPC port (default: 10012)"
            echo "  --output DIR           Output directory (default: ~/.lnget/signer/credentials-bundle)"
            echo ""
            echo "Container auto-detection: looks for litd-signer container."
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

# Auto-detect container if not native, no container, and no remote.
if [ "$NATIVE" = false ] && [ -z "$CONTAINER" ] && [ -z "$RPCSERVER" ]; then
    if command -v docker &>/dev/null; then
        if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'litd-signer'; then
            CONTAINER="litd-signer"
        fi
    fi

    # If no container found, fall back to native.
    if [ -z "$CONTAINER" ]; then
        NATIVE=true
    fi
fi

# Apply default lnddir if not set.
if [ -z "$LND_SIGNER_DIR" ]; then
    if [ -n "$CONTAINER" ]; then
        LND_SIGNER_DIR="/root/.lnd"
    else
        LND_SIGNER_DIR="$HOME/.lnd-signer"
    fi
fi

BUNDLE_DIR="${OUTPUT_DIR:-$LNGET_SIGNER_DIR/credentials-bundle}"

echo "=== Exporting Credentials Bundle ==="
echo ""
echo "Network:    $NETWORK"
if [ -n "$CONTAINER" ]; then
    echo "Container:  $CONTAINER"
elif [ -n "$RPCSERVER" ]; then
    echo "Remote:     $RPCSERVER"
else
    echo "Signer dir: $LND_SIGNER_DIR"
fi
echo "Output:     $BUNDLE_DIR"
echo ""

# Verify lncli is available.
if [ -n "$CONTAINER" ]; then
    if ! docker exec "$CONTAINER" which lncli &>/dev/null; then
        echo "Error: lncli not found in container '$CONTAINER'." >&2
        exit 1
    fi
elif [ -z "$RPCSERVER" ]; then
    if ! command -v lncli &>/dev/null; then
        echo "Error: lncli not found. Run install.sh first." >&2
        exit 1
    fi
else
    if ! command -v lncli &>/dev/null; then
        echo "Error: lncli not found. Install lncli to connect to the remote signer." >&2
        exit 1
    fi
fi

# Create bundle directory.
mkdir -p "$BUNDLE_DIR"
chmod 700 "$BUNDLE_DIR"

# Build lncli connection flags.
LNCLI_CONN=()
if [ -n "$RPCSERVER" ]; then
    LNCLI_CONN+=("--rpcserver=$RPCSERVER")
else
    LNCLI_CONN+=("--rpcserver=localhost:$RPC_PORT")
fi
LNCLI_CONN+=("--lnddir=$LND_SIGNER_DIR" "--network=$NETWORK")
if [ -n "$TLSCERTPATH" ]; then
    LNCLI_CONN+=("--tlscertpath=$TLSCERTPATH")
fi
if [ -n "$MACAROONPATH" ]; then
    LNCLI_CONN+=("--macaroonpath=$MACAROONPATH")
fi

# Export accounts list.
echo "Exporting accounts..."
if [ -n "$CONTAINER" ]; then
    docker exec "$CONTAINER" lncli "${LNCLI_CONN[@]}" \
        wallet accounts list > "$BUNDLE_DIR/accounts.json"
else
    lncli "${LNCLI_CONN[@]}" \
        wallet accounts list > "$BUNDLE_DIR/accounts.json"
fi

if [ ! -s "$BUNDLE_DIR/accounts.json" ]; then
    echo "Error: Failed to export accounts. Is the signer running and unlocked?" >&2
    exit 1
fi
echo "  accounts.json exported."

# Copy TLS certificate.
if [ -n "$RPCSERVER" ]; then
    # Remote mode: use the provided --tlscertpath as the bundle cert.
    if [ -z "$TLSCERTPATH" ]; then
        echo "Error: --tlscertpath required for remote export (needed for bundle)." >&2
        exit 1
    fi
    cp "$TLSCERTPATH" "$BUNDLE_DIR/tls.cert"
elif [ -n "$CONTAINER" ]; then
    TLS_CERT="$LND_SIGNER_DIR/tls.cert"
    docker cp "$CONTAINER:$TLS_CERT" "$BUNDLE_DIR/tls.cert" 2>/dev/null
    if [ ! -f "$BUNDLE_DIR/tls.cert" ]; then
        echo "Error: TLS certificate not found at $TLS_CERT in container '$CONTAINER'" >&2
        exit 1
    fi
else
    TLS_CERT="$LND_SIGNER_DIR/tls.cert"
    if [ ! -f "$TLS_CERT" ]; then
        echo "Error: TLS certificate not found at $TLS_CERT" >&2
        exit 1
    fi
    cp "$TLS_CERT" "$BUNDLE_DIR/tls.cert"
fi
echo "  tls.cert copied."

# Copy admin macaroon.
if [ -n "$RPCSERVER" ]; then
    # Remote mode: use the provided --macaroonpath as the bundle macaroon.
    if [ -z "$MACAROONPATH" ]; then
        echo "Error: --macaroonpath required for remote export (needed for bundle)." >&2
        exit 1
    fi
    cp "$MACAROONPATH" "$BUNDLE_DIR/admin.macaroon"
elif [ -n "$CONTAINER" ]; then
    MACAROON="$LND_SIGNER_DIR/data/chain/bitcoin/$NETWORK/admin.macaroon"
    docker cp "$CONTAINER:$MACAROON" "$BUNDLE_DIR/admin.macaroon" 2>/dev/null
    if [ ! -f "$BUNDLE_DIR/admin.macaroon" ]; then
        echo "Error: Admin macaroon not found at $MACAROON in container '$CONTAINER'" >&2
        exit 1
    fi
else
    MACAROON="$LND_SIGNER_DIR/data/chain/bitcoin/$NETWORK/admin.macaroon"
    if [ ! -f "$MACAROON" ]; then
        echo "Error: Admin macaroon not found at $MACAROON" >&2
        exit 1
    fi
    cp "$MACAROON" "$BUNDLE_DIR/admin.macaroon"
fi
echo "  admin.macaroon copied."
echo ""

# Create portable base64-encoded tar.gz bundle.
BUNDLE_ARCHIVE="$LNGET_SIGNER_DIR/credentials-bundle.tar.gz.b64"
echo "Creating portable bundle..."
tar -czf - -C "$BUNDLE_DIR" accounts.json tls.cert admin.macaroon | base64 > "$BUNDLE_ARCHIVE"
echo "  Bundle saved to $BUNDLE_ARCHIVE"
echo ""

echo "=== Credentials Bundle Ready ==="
echo ""
echo "Bundle contents:"
echo "  $BUNDLE_DIR/accounts.json    — account xpubs for watch-only import"
echo "  $BUNDLE_DIR/tls.cert         — signer TLS certificate"
echo "  $BUNDLE_DIR/admin.macaroon   — signer admin macaroon"
echo ""
echo "Portable bundle (base64):"
echo "  $BUNDLE_ARCHIVE"
echo ""
echo "To transfer, either:"
echo "  1. Copy the credentials-bundle/ directory to the agent machine"
echo "  2. Copy-paste the base64 string from $BUNDLE_ARCHIVE"
echo ""
echo "On the agent machine:"
echo "  skills/lnd/scripts/import-credentials.sh --bundle <path-or-base64>"


path: references/architecture.md
content: # Remote Signer Architecture

## Overview

The remote signer architecture splits an lnd Lightning node into two separate
processes running on different machines:

1. **Signer node** — holds all private key material, performs signing
2. **Watch-only node** — handles networking, channels, routing, payments

This separation ensures that even if the machine running the watch-only node
(the "agent machine") is fully compromised, the attacker cannot extract private
keys or sign arbitrary transactions.

## Two-Node Architecture

```
┌─────────────────────────────┐     ┌─────────────────────────────┐
│      Agent Machine          │     │      Signer Machine         │
│                             │     │                             │
│  ┌───────────────────────┐  │     │  ┌───────────────────────┐  │
│  │  lnd (watch-only)     │  │     │  │  lnd (signer)         │  │
│  │                       │  │     │  │                       │  │
│  │  - Neutrino backend   │◄─┼─gRPC┼─►│  - Holds seed          │  │
│  │  - Channel state      │  │     │  │  - Signs commitments   │  │
│  │  - Routes payments    │  │     │  │  - Signs on-chain txs  │  │
│  │  - Manages peers      │  │     │  │  - No p2p networking   │  │
│  │  - NO private keys    │  │     │  │  - Minimal surface     │  │
│  └───────────────────────┘  │     │  └───────────────────────┘  │
│                             │     │                             │
│  Imported from signer:      │     │  Never exported:            │
│  - accounts.json (xpubs)   │     │  - Seed mnemonic            │
│  - tls.cert                │     │  - Private keys             │
│  - admin.macaroon          │     │  - Wallet DB key material   │
└─────────────────────────────┘     └─────────────────────────────┘
```

## What Gets Exported

The credential bundle contains three files:

### accounts.json

Output of `lncli wallet accounts list`. Contains extended public keys (xpubs)
for all lnd accounts. These allow the watch-only node to:

- Derive public keys and addresses
- Track on-chain balances
- Construct unsigned transactions

They do **not** allow signing or spending.

### tls.cert

The signer's TLS certificate. The watch-only node uses this to establish an
authenticated, encrypted gRPC connection to the signer. This prevents
man-in-the-middle attacks on the signing channel.

### admin.macaroon

The signer's admin macaroon for RPC authentication. The watch-only node presents
this when requesting signatures. For production, replace with a scoped macaroon
that only allows signing operations.

## What Never Leaves the Signer

- **Seed mnemonic** — the 24-word BIP39 phrase from which all keys derive
- **Private keys** — funding keys, revocation keys, HTLC keys
- **Wallet database key material** — encrypted private key data in the wallet DB

## Signing Flow

When the watch-only node needs a signature (e.g., committing a channel state
update or broadcasting an on-chain transaction):

1. Watch-only node constructs the unsigned transaction
2. Watch-only node sends a signing request to the signer via gRPC
3. Signer validates the request and produces the signature
4. Signer returns the signature to the watch-only node
5. Watch-only node assembles the signed transaction and broadcasts it

## Threat Model

### Compromised agent machine

**Impact:** Attacker can see channel states, balances, and payment history.
Attacker can attempt to route payments through existing channels.

**Cannot:** Extract private keys, sign arbitrary transactions, sweep funds to
attacker-controlled addresses, forge channel close transactions.

### Compromised signer machine

**Impact:** Full compromise — attacker has seed and all keys. Can sign arbitrary
transactions and sweep all funds.

**Mitigation:** The signer should have minimal attack surface. No unnecessary
services, no web browser, no agent software. Ideally dedicated hardware.

### Network interception (gRPC channel)

**Impact:** Without TLS cert validation, attacker could intercept signing
requests.

**Mitigation:** TLS with certificate pinning (the watch-only node has a copy of
the signer's TLS cert and validates against it).

## Macaroon Scoping

For production deployments, the admin macaroon should be replaced with a
custom macaroon that only grants the minimum permissions needed:

```bash
# On the signer, bake a signing-only macaroon
lncli --rpcserver=localhost:10012 --lnddir=~/.lnd-signer \
    bakemacaroon uri:/signrpc.Signer/SignOutputRaw \
    uri:/signrpc.Signer/ComputeInputScript \
    uri:/signrpc.Signer/MuSig2Sign \
    uri:/walletrpc.WalletKit/DeriveKey \
    uri:/walletrpc.WalletKit/DeriveNextKey \
    --save_to=~/.lnd-signer/data/chain/bitcoin/mainnet/signer-only.macaroon
```

## Future Enhancements

- **litd accounts:** Use Lightning Terminal's account system for even finer
  access control and spending limits
- **Hardware signing devices:** Replace the signer lnd with a hardware security
  module (HSM) or hardware wallet for tamper-resistant key storage
- **Multi-party signing:** Require multiple signers to approve high-value
  transactions (threshold signatures)
- **Macaroon rotation:** Automatically rotate macaroons on a schedule to limit
  the window of compromise


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: clean (LOW)