Files
archy/scripts/security/rotate-lnd-macaroon.sh
T
archipelagoandClaude Opus 5 d15cd58d7f
Demo images / Build & push demo images (push) Successful in 3m34s
feat(lnd): rotate Lightning macaroons from the dashboard, and stop stranding BTCPay
Rotating LND's macaroons was an SSH-only script, which in practice meant it did
not happen — while a macaroon is a bearer token with no revocation and no expiry,
so anything that ever read one keeps the ability to spend until they are
replaced. Settings → Lightning credentials now does it behind the node password,
shows a step checklist, and refuses to report success unless it has confirmed the
node identity and channel census are unchanged.

Three findings from performing a real rotation on a dev node, each fixed here:

1. BTCPay was left holding a dead credential, silently. Its connection string
   carries the macaroon INLINE (LND's datadir is owned by its container subuid,
   so btcpay cannot bind-mount the file), and the daemon only regenerates that
   secret when LND's TLS cert thumbprint changes — which macaroon rotation does
   not touch. Result: btcpay up, LND up, both healthy, every Lightning payment
   failing, nothing anywhere saying why.

2. Rewriting the secret is not enough to fix it. `secret_env_hash` makes the
   change visible as env drift, but the reconcile loop runs `ExistingOnly` at
   boot AND periodically, and there it deliberately leaves running
   restart-sensitive apps untouched — observed once per tick for half an hour on
   the dev node. So this reuses FED-07's `credential_rotated` carve-out via a new
   default-no-op `ContainerOrchestrator::mark_credential_rotated`, on the same
   reasoning: restart sensitivity protects apps that are working, and this one is
   working only in appearance. The shell script cannot reach an in-process flag,
   so it removes the container and lets desired-state recovery rebuild it.

3. LND stayed locked forever on a loaded node. The unlocker is only served after
   channel.db/graph.db/wallet.db open, measured at 2m38s on a box running 30
   containers; the unlock helper gave up at ~60s. That is not a harmless retry —
   reconcile records the post-start hook as failed, restarts LND, and the slow
   open begins again, so the wallet never opens and every LND-dependent app stays
   broken. The not-ready budget is now ~10 minutes; a genuinely wrong password
   still exits on the first pass via `all_rejected`.

Safety properties worth not regressing:
- No macaroon content in any response, error, log line or the polled progress
  feed — digests and byte counts only.
- Rotation unlocks via a new `unlock_existing_wallet_no_wipe`, so there is no
  code path from "rotate my credentials" to `recreate_wallet_destructively`. A
  wallet whose password this node lacks fails the rotation with the wallet intact.
- Channels are compared as active+inactive totals, not `num_active_channels`,
  which legitimately dips after any restart while peers reconnect.
- Backup verified by file count before anything is deleted.

Verified: cargo check + fmt clean, 6 new unit tests and the 6 existing
container::lnd tests pass, vue-tsc clean, and the built bundle contains the three
new RPC method names (the frontend build can silently no-op).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:45:51 -04:00

344 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Rotate this node's LND macaroons after the /lnd-connect-info leak.
#
# WHY THIS EXISTS
# GET /lnd-connect-info used to answer unauthenticated callers with the LND
# admin macaroon, the TLS cert, the gRPC/REST ports and the node's onion
# address. Anything that reached port 18083 — any fips0 mesh peer, LAN host
# or Tailscale peer — could take it. Every macaroon on an affected node must
# be treated as known to an attacker.
#
# WHAT ROTATION ACTUALLY DOES
# LND derives every macaroon it issues from a root key kept in macaroons.db.
# Remove that root key and the macaroon files, restart, and LND mints a fresh
# root key and a fresh set of macaroons on unlock. Every previously issued
# macaroon — including any the attacker holds — stops verifying.
#
# WHY YOUR FUNDS AND CHANNELS SURVIVE
# Macaroons are bearer tokens, not keys. Coins live in wallet.db and channel
# state in channel.db; channels are secured by the node's identity and channel
# keys, none of which are derived from the macaroon root key. This script
# never touches, moves or opens either database. The wallet is not re-created,
# the seed is not re-entered, and no channel is closed or force-closed.
#
# The only interruption is the LND restart itself, which is the same event as
# a reboot or an update — peers reconnect and channels resume. What this
# script verifies is exactly that: it records the node's identity pubkey and
# its channel counts BEFORE, and aborts loudly if either differs after.
#
# Note it does NOT assert wallet.db is byte-identical, which would be the
# wrong test: btcwallet records chain-sync progress inside wallet.db, so the
# file legitimately changes on every start. Asserting byte-identity would fire
# a frightening false alarm on a completely healthy rotation.
#
# WHAT IT DELIBERATELY NEVER DOES
# It never reads, prints, logs or copies a macaroon's CONTENT. Everything it
# reports is a SHA-256 digest or a file size, which is enough to prove the
# material changed without disclosing it to the terminal, the scrollback or
# whoever is reading over your shoulder.
#
# THE ORDERING GUARD
# Rotating before the leak is patched is worse than useless: the new macaroon
# is readable through the same open door within seconds, and you would think
# you were safe. So this script REFUSES to rotate unless the running binary
# carries the fix. Override only if you genuinely know better.
set -uo pipefail
LND_DIR="/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet"
BIN="/usr/local/bin/archipelago"
CONTAINER="lnd"
APPLY=no; ASSUME_YES=no; FORCE_UNPATCHED=no
usage() {
cat <<'USAGE'
Usage: rotate-lnd-macaroon.sh [--apply] [--yes] [--force-unpatched]
(no flags) Detect and report only. Changes nothing. THE DEFAULT.
--apply Perform the rotation. Requires --yes as well.
--yes Confirm a destructive-to-credentials action.
--force-unpatched Rotate even though the running binary lacks the
/lnd-connect-info fix. You will almost certainly be
re-leaking the new macaroon. Not recommended.
Exit: 0 ok / verdict clean, 1 error or aborted, 2 rotation needed (detect mode).
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=yes; shift ;;
--yes) ASSUME_YES=yes; shift ;;
--force-unpatched) FORCE_UNPATCHED=yes; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 1 ;;
esac
done
say() { printf '%s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
# Digest helper. Uses sudo because LND's data dir is 0700 and owned by the
# container's mapped uid. Prints ONLY a digest, never a byte of content.
digest() {
sudo sha256sum "$1" 2>/dev/null | awk '{print $1}'
}
# Enumerate the macaroon material. This MUST run under sudo rather than as a
# shell glob: the LND data dir is 0700 owned by the container's mapped uid, so
# `"$LND_DIR"/*.macaroon` does not expand in this (unprivileged) shell. It
# would silently stay literal, making both the backup loop and the removal
# loop no-ops while every surrounding step still reported success.
macaroon_files() {
sudo find "$LND_DIR" -maxdepth 1 \
\( -name '*.macaroon' -o -name 'macaroons.db' \) 2>/dev/null
}
say "── LND macaroon rotation ─────────────────────────────────────────"
sudo test -d "$LND_DIR" || die "LND data dir not found at $LND_DIR — is LND installed on this node?"
# ── The ordering guard ────────────────────────────────────────────────
# The fix adds this exact string to the binary. Its presence is the only
# machine-checkable evidence that the door is shut on THIS node.
PATCH_MARKER="/auth/session-check"
if sudo grep -qa -- "$PATCH_MARKER" "$BIN" 2>/dev/null; then
say "patch status : PRESENT — the running binary carries the /lnd-connect-info fix"
PATCHED=yes
else
say "patch status : ABSENT — this binary still leaks /lnd-connect-info"
PATCHED=no
fi
# ── Live reachability check ───────────────────────────────────────────
# Proves the hole from the outside rather than trusting the marker alone.
LEAK_CODE=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/lnd-connect-info 2>/dev/null || echo "000")
case "$LEAK_CODE" in
200) say "live probe : LEAKING — unauthenticated GET returned 200" ;;
401|403) say "live probe : closed — unauthenticated GET returned $LEAK_CODE" ;;
000) say "live probe : lnd-ui not reachable on :18083 (app may be stopped)" ;;
*) say "live probe : unauthenticated GET returned $LEAK_CODE" ;;
esac
OLD_ADMIN=$(digest "$LND_DIR/admin.macaroon")
OLD_ROOT=$(digest "$LND_DIR/macaroons.db")
say "admin.macaroon : ${OLD_ADMIN:-<absent>}"
say "macaroons.db : ${OLD_ROOT:-<absent>}"
# Node identity + channel census BEFORE. `lncli` runs INSIDE the container and
# reads the macaroon off its own disk, so the secret never crosses into this
# script, its output, or the operator's scrollback.
#
# It reports active AND inactive channel counts separately, because only their
# SUM is a safety property. `num_active_channels` counts channels whose peer is
# currently online, so it legitimately dips straight after any restart while
# peers reconnect — asserting on it alone would abort a perfectly healthy
# rotation. What must not change is the total number of channels the node
# holds, and its identity.
lnd_state() {
podman exec "$CONTAINER" lncli --network=mainnet getinfo 2>/dev/null \
| python3 -c 'import json,sys
try: d=json.load(sys.stdin)
except Exception: raise SystemExit(1)
print("%s %s %s %s" % (d.get("identity_pubkey",""), d.get("num_active_channels",0),
d.get("num_inactive_channels",0), d.get("num_pending_channels",0)))' 2>/dev/null
}
OLD_STATE=$(lnd_state)
if [ -n "$OLD_STATE" ]; then
set -- $OLD_STATE
OLD_PUBKEY="$1"; OLD_ACTIVE="$2"; OLD_INACTIVE="$3"; OLD_PENDING="$4"
OLD_TOTAL=$((OLD_ACTIVE + OLD_INACTIVE))
say "node identity : ${OLD_PUBKEY:0:16}…"
say "channels : $OLD_TOTAL open ($OLD_ACTIVE active, $OLD_INACTIVE inactive), $OLD_PENDING pending (must survive)"
else
OLD_PUBKEY=""; OLD_ACTIVE=""; OLD_INACTIVE=""; OLD_PENDING=""; OLD_TOTAL=""
say "channels : could not read LND state (locked or down) — see below"
fi
if [ "$APPLY" != yes ]; then
say
say "Detect-only. Re-run with --apply --yes to rotate."
[ "$PATCHED" = yes ] || say "Patch this node FIRST, or the new macaroon leaks immediately."
exit 2
fi
[ "$ASSUME_YES" = yes ] || die "--apply requires --yes (this invalidates every existing macaroon)"
if [ "$PATCHED" != yes ] && [ "$FORCE_UNPATCHED" != yes ]; then
die "refusing to rotate on an unpatched node — the new macaroon would leak through the same hole. Deploy the fix first, or pass --force-unpatched if you truly intend this."
fi
# ── Back up, so a mistake is recoverable ──────────────────────────────
# Kept 0700 and OUTSIDE the dir LND rescans. Still secret material: it is the
# old root key. Delete it once you have confirmed every client re-paired.
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="/var/lib/archipelago/lnd/macaroon-rotation-$STAMP"
sudo mkdir -p "$BACKUP" || die "could not create $BACKUP"
sudo chmod 700 "$BACKUP"
mapfile -t MAC_FILES < <(macaroon_files)
[ "${#MAC_FILES[@]}" -gt 0 ] || die "no macaroon material found in $LND_DIR — nothing to rotate, and restarting LND for no reason would be a pointless outage"
say
say "backing up old macaroon material to $BACKUP (0700, contents never printed)"
for f in "${MAC_FILES[@]}"; do
sudo cp -a "$f" "$BACKUP/" || die "backup of $(basename "$f") failed — aborting before any deletion"
say " backed up $(basename "$f")"
done
# Count what actually landed. A backup that silently copied nothing is the one
# failure mode that would make the deletion below unrecoverable.
BACKED_UP=$(sudo find "$BACKUP" -maxdepth 1 -type f 2>/dev/null | wc -l)
[ "$BACKED_UP" -eq "${#MAC_FILES[@]}" ] \
|| die "backup incomplete — $BACKED_UP of ${#MAC_FILES[@]} files in $BACKUP. Refusing to delete anything."
say "stopping $CONTAINER"
podman stop "$CONTAINER" >/dev/null 2>&1 || say " (stop reported non-zero; continuing to check state)"
# Only now, with a verified backup in hand, remove the credential material.
say "removing macaroon root key and issued macaroons"
for f in "${MAC_FILES[@]}"; do
sudo rm -f "$f" || die "could not remove $(basename "$f") — restore from $BACKUP"
done
say "starting $CONTAINER — archipelago auto-unlocks and LND re-mints on unlock"
podman start "$CONTAINER" >/dev/null 2>&1 || die "could not start $CONTAINER — restore from $BACKUP"
# ── Wait for regeneration ─────────────────────────────────────────────
printf 'waiting for a fresh admin.macaroon'
NEW_ADMIN=""
for _ in $(seq 1 60); do
sleep 5
NEW_ADMIN=$(digest "$LND_DIR/admin.macaroon")
[ -n "$NEW_ADMIN" ] && break
printf '.'
done
printf '\n'
[ -n "$NEW_ADMIN" ] || die "no new admin.macaroon after 5 minutes. LND may not have unlocked. Old material is intact in $BACKUP — restore it there and investigate before retrying."
# ── Verify: credentials changed, node and channels did not ────────────
FAIL=""
[ "$NEW_ADMIN" != "$OLD_ADMIN" ] || FAIL="$FAIL admin-macaroon-UNCHANGED"
# Deliberately NOT a wallet.db byte-identity check. btcwallet records chain
# sync progress inside wallet.db, so that file legitimately changes on every
# start; asserting equality would fire a frightening false alarm on a
# completely healthy rotation. The meaningful invariant is that this is still
# the SAME Lightning node holding the SAME channels — so assert that instead.
printf 'waiting for LND to report its state'
NEW_STATE=""
for _ in $(seq 1 60); do
NEW_STATE=$(lnd_state)
[ -n "$NEW_STATE" ] && break
printf '.'
sleep 5
done
printf '\n'
if [ -n "$OLD_PUBKEY" ]; then
if [ -n "$NEW_STATE" ]; then
set -- $NEW_STATE
NEW_PUBKEY="$1"; NEW_ACTIVE="$2"; NEW_INACTIVE="$3"; NEW_PENDING="$4"
NEW_TOTAL=$((NEW_ACTIVE + NEW_INACTIVE))
[ "$NEW_PUBKEY" = "$OLD_PUBKEY" ] || FAIL="$FAIL NODE-IDENTITY-CHANGED"
[ "$NEW_TOTAL" = "$OLD_TOTAL" ] || FAIL="$FAIL OPEN-CHANNELS-$OLD_TOTAL-to-$NEW_TOTAL"
[ "$NEW_PENDING" = "$OLD_PENDING" ] || FAIL="$FAIL PENDING-CHANNELS-$OLD_PENDING-to-$NEW_PENDING"
say
say "node identity : ${NEW_PUBKEY:0:16}… (unchanged)"
say "channels : $NEW_TOTAL open ($NEW_ACTIVE active, $NEW_INACTIVE inactive), $NEW_PENDING pending"
if [ "$NEW_ACTIVE" != "$OLD_ACTIVE" ]; then
say " active count differs from before ($OLD_ACTIVE -> $NEW_ACTIVE) — this is"
say " normal for a few minutes after any restart while peers reconnect."
fi
else
FAIL="$FAIL LND-STATE-UNREADABLE-AFTER"
fi
else
say
say "channels : NOT VERIFIED — LND state was already unreadable before the"
say " rotation, so there is no baseline to compare against."
say " Check 'lncli getinfo' yourself before trusting this run."
fi
say
say "new admin.macaroon : $NEW_ADMIN"
if [ -n "$FAIL" ]; then
say
die "rotation verification FAILED:$FAIL — old material is in $BACKUP"
fi
# ── BTCPay's inline copy ──────────────────────────────────────────────
# BTCPay reaches the internal LND node with a connection string that carries
# the macaroon INLINE as hex, not as a file path: LND's datadir is owned by its
# container's mapped uid, so btcpay cannot bind-mount the file. That copy is
# therefore now a dead credential, and nothing else will notice — the daemon
# only regenerates this secret when LND's TLS *cert* thumbprint changes, which
# macaroon rotation does not touch. The node keeps looking healthy (btcpay up,
# LND up) while every Lightning invoice BTCPay tries to create fails.
#
# Deleting the secret file gets the daemon to regenerate it from the new
# macaroon on its next reconcile tick. That is necessary but NOT sufficient, and
# the difference matters: the RUNNING container still holds the dead value, and
# the periodic reconciler only ever runs in `ExistingOnly` mode, where env drift
# on a restart-sensitive app (btcpay-server is one) is detected and then
# deliberately skipped — "leaving running restart-sensitive app untouched". So
# the container has to be recreated on purpose. The dashboard path
# (Settings → Lightning credentials) does this itself by flagging the app as
# credential-rotated; a shell script cannot reach that in-process flag, so it
# removes the container instead and lets the orchestrator's own desired-state
# recovery rebuild it around unchanged data, ports and volumes.
#
# Nothing is printed but a path — never the value.
BTCPAY_SECRET="/var/lib/archipelago/secrets/btcpay-lnd-connection"
BTCPAY_NOTE=no
if sudo test -f "$BTCPAY_SECRET"; then
if sudo rm -f "$BTCPAY_SECRET"; then
say
say "btcpay : removed its stale connection string ($BTCPAY_SECRET)."
say " The daemon regenerates it from the new macaroon within a minute."
BTCPAY_NOTE=yes
if podman container exists btcpay-server 2>/dev/null; then
say " Recreating btcpay-server so it stops using the dead one."
podman stop btcpay-server >/dev/null 2>&1 || true
if podman rm -f btcpay-server >/dev/null 2>&1; then
say " Removed; the orchestrator rebuilds it around its existing"
say " data (it was running, so desired-state recovery restores it)."
else
say " ⚠ could not remove btcpay-server. Its Lightning payments will"
say " fail until it is recreated."
BTCPAY_NOTE=warn
fi
fi
else
say
say "btcpay : ⚠ could not remove $BTCPAY_SECRET. BTCPay is still holding"
say " the OLD macaroon, so its Lightning payments will fail until"
say " that file is deleted and btcpay-server is recreated."
BTCPAY_NOTE=warn
fi
fi
say
say "✅ Rotated. Every macaroon issued before now no longer verifies."
say
say "WHAT BREAKS, AND WHAT TO DO:"
say " Anything paired with the old admin macaroon must be re-paired — most"
say " importantly Zeus or any other remote wallet. Open the LND app in the UI"
say " and scan the new pairing QR; it serves the new macaroon."
say
say " Your funds and channels are untouched: the node kept its identity and"
say " no channel was closed."
if [ "${BTCPAY_NOTE:-no}" != no ]; then
say
say " CONFIRM BTCPAY CAME BACK. A silent failure here looks identical to success:"
say " btcpay stays up and healthy while every Lightning payment it tries fails."
say " podman inspect btcpay-server --format '{{.Created}}' # should be just now"
say " sudo test -f $BTCPAY_SECRET && echo regenerated"
fi
say
say " Once every client is re-paired, delete the backup — it holds the OLD"
say " root key, which is still sensitive:"
say " sudo rm -rf $BACKUP"
exit 0