Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,30 @@ if [[ "$1" == "lnd" || "$1" == "lncli" ]]; then
# baked ones included, so all of them are cleared rather than left behind
# as tokens that no longer work. Runs before lnd starts, so nothing is
# holding the files open. Bump LND_MACAROON_ROTATION_ID to rotate again.
if [[ "${LND_MACAROON_ROTATION_ID}" ]]; then
# An interrupted password change can leave wallet.db and macaroons.db
# encrypted with different passwords. Recreate only authentication data;
# the unlocker will try the saved pending password before the old one.
PENDING_PASSWORD=false
if [[ -f "$LNDUNLOCK_FILE" ]]; then
PENDING_PASSWORD=$(jq -r 'has("wallet_password_pending")' "$LNDUNLOCK_FILE")
if [[ "$PENDING_PASSWORD" == true ]]; then
jq -e '.wallet_password_pending | type == "string" and length >= 8' "$LNDUNLOCK_FILE" >/dev/null
fi
fi
if [[ "${LND_MACAROON_ROTATION_ID}" || "$PENDING_PASSWORD" == true ]]; then
ROTATION_MARKER="$LND_DATA/.macaroon-rotated-$LND_MACAROON_ROTATION_ID"
if [ ! -f "$ROTATION_MARKER" ]; then
if [[ ! -f "$ROTATION_MARKER" || "$PENDING_PASSWORD" == true ]]; then
echo "[lnd_unlock_entrypoint] Rotating macaroons ($LND_MACAROON_ROTATION_ID), ALL existing macaroons are being invalidated"
# -exec rm rather than -delete, busybox find on alpine may not have it
find "$LND_DATA" -type f \( -name '*.macaroon' -o -name 'macaroons.db' \) \
-print -exec rm -f {} \;
touch "$ROTATION_MARKER"
if [[ "${LND_MACAROON_ROTATION_ID}" ]]; then touch "$ROTATION_MARKER"; fi
echo "[lnd_unlock_entrypoint] Macaroons removed, lnd will regenerate them. Every client must be re-paired"
fi
fi

# hit up the auto initializer and unlocker on separate process to do it's work
export LND_DAEMON_PID=$$
./docker-initunlocklnd.sh $NETWORK $ENV &

ln -sfn "$LND_DATA" /root/.lnd
Expand Down
73 changes: 54 additions & 19 deletions docker-initunlocklnd.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
#!/bin/bash
set -e
umask 077

save_unlock_file() {
local temporary
temporary=$(mktemp "$LNDUNLOCK_FILE.tmp.XXXXXX")
jq -c "$@" "$LNDUNLOCK_FILE" > "$temporary"
sync
mv -f "$temporary" "$LNDUNLOCK_FILE"
sync
}

restart_lnd() {
echo "[initunlocklnd] Restarting LND to finish the saved password change"
if [[ "${LND_DAEMON_PID:-}" =~ ^[0-9]+$ && "$LND_DAEMON_PID" == "$PPID" ]]; then
kill -TERM "$LND_DAEMON_PID"
Comment on lines +16 to +17
else
echo "[initunlocklnd] Restart the LND container to continue"
fi
exit 1
}

echo "[initunlocklnd] Waiting 2 seconds for lnd..."
sleep 2
Expand Down Expand Up @@ -64,47 +84,62 @@ if [ -f "$WALLET_FILE" ]; then
# the older version.
WALLETPASS_BASE64=$(echo $WALLETPASS | tr -d '\n\r' | base64)

# Wallets still on the legacy shared default password ("hellorockstar",
# or an empty file field falling back to it) are migrated to a random
# per-instance password. changepassword on a locked wallet changes +
# unlocks, and it only exists while locked, so migration must happen
# INSTEAD of a plain unlock, not after it. The file is only rewritten
# AFTER changepassword succeeds, so a failed migration never strands
# the wallet: next start just retries. The mnemonic (or the "Seed
# removed" placeholder) is preserved as-is.
# ChangePassword can change wallet.db and then fail on macaroons.db.
# Save the replacement BEFORE the RPC and retain both passwords until
# success. On restart, the entrypoint resets the macaroon store so a
# normal unlock can finish a partially committed change.
PENDING_PASSWORD=$(jq -r 'has("wallet_password_pending")' "$LNDUNLOCK_FILE")
MIGRATE_DEFAULT=0
if [[ "$WALLETPASS" == "hellorockstar" ]]; then
if [[ "$WALLETPASS" == "hellorockstar" || "$PENDING_PASSWORD" == true ]]; then
MIGRATE_DEFAULT=1
fi

if [[ $MIGRATE_DEFAULT == 1 ]]; then
NEWPASS=$(head -c 32 /dev/urandom | base64 | tr -d '\n')
NEWPASS_BASE64=$(echo $NEWPASS | tr -d '\n\r' | base64)
if [[ "$PENDING_PASSWORD" == true ]]; then
NEWPASS=$(jq -er '.wallet_password_pending | select(type == "string" and length >= 8)' "$LNDUNLOCK_FILE")
else
NEWPASS=$(head -c 32 /dev/urandom | base64 | tr -d '\n')
[[ ${#NEWPASS} == 44 ]]
save_unlock_file --arg pw "$NEWPASS" '.wallet_password_pending = $pw'
fi
NEWPASS_BASE64=$(printf %s "$NEWPASS" | base64 | tr -d '\n')
# line feed hex code 0x0A: oldest installs have the default
# password including a trailing line feed, so if the corrected
# one fails we retry the rotation from that variant
WALLETPASS_BASE64_CURRENT=$(echo $WALLETPASS | base64)

rotate_response=""
if [[ "$PENDING_PASSWORD" == true ]]; then
rotate_response=$(curl -s --cacert "$CA_CERT" -X POST -H "$MACAROON_HEADER" \
-d '{ "wallet_password":"'$NEWPASS_BASE64'" }' \
$LND_REST_LISTEN_HOST/v1/unlockwallet)
fi

# Note: a successful changepassword returns {} when macaroons are
# disabled, or {"admin_macaroon":"..."} when they are enabled
rotate_response=$(curl -s --cacert "$CA_CERT" -X POST -H "$MACAROON_HEADER" \
-d '{ "current_password":"'$WALLETPASS_BASE64'", "new_password":"'$NEWPASS_BASE64'" }' \
$LND_REST_LISTEN_HOST/v1/changepassword)
if [[ "$rotate_response" != "{}" && "$rotate_response" != *'"admin_macaroon"'* ]]; then
if [[ "$PENDING_PASSWORD" != true ]] || \
jq -e '.message == "invalid passphrase for master public key"' >/dev/null <<< "$rotate_response"; then
rotate_response=$(curl -s --cacert "$CA_CERT" -X POST -H "$MACAROON_HEADER" \
-d '{ "current_password":"'$WALLETPASS_BASE64'", "new_password":"'$NEWPASS_BASE64'" }' \
$LND_REST_LISTEN_HOST/v1/changepassword)
fi
if jq -e '.message == "invalid passphrase for master public key"' >/dev/null <<< "$rotate_response"; then
rotate_response=$(curl -s --cacert "$CA_CERT" -X POST -H "$MACAROON_HEADER" \
-d '{ "current_password":"'$WALLETPASS_BASE64_CURRENT'", "new_password":"'$NEWPASS_BASE64'" }' \
$LND_REST_LISTEN_HOST/v1/changepassword)
fi

response=""
if [[ "$rotate_response" == "{}" || "$rotate_response" == *'"admin_macaroon"'* ]]; then
jq -c --arg pw "$NEWPASS" '.wallet_password = $pw' "$LNDUNLOCK_FILE" > "$LNDUNLOCK_FILE.tmp" \
&& mv "$LNDUNLOCK_FILE.tmp" "$LNDUNLOCK_FILE"
save_unlock_file '.wallet_password = .wallet_password_pending | del(.wallet_password_pending)'
echo "[initunlocklnd] Migrated wallet off the default password; the new random password is in $LNDUNLOCK_FILE"
response="{}"
else
echo "[initunlocklnd] WARNING: migration off the default password failed, lnd returned: $rotate_response"
echo "[initunlocklnd] Wallet is still locked, migration will be retried on next start"
echo "[initunlocklnd] Both passwords remain in $LNDUNLOCK_FILE; restart to retry safely"
if jq -e '.message == "default root key not found"' >/dev/null <<< "$rotate_response"; then
restart_lnd
fi
exit 1
fi
else
Expand Down Expand Up @@ -176,4 +211,4 @@ if [ ! -z "$LND_HOST_FOR_LOOP" ]; then
else
echo "[initunlocklnd] Loop can't be started without MACAROON"
fi
fi
fi
100 changes: 100 additions & 0 deletions tests/docker-password-migration.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/bin/bash
# Run with bash tests/docker-password-migration.sh. Requires Docker, curl, jq.
# Uses only disposable regtest data and the unchanged released LND binary.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
IMAGE=btcpayserver/lnd:v0.21.3-beta-1
NAME=btcpay-password-test-$$
BTC=$NAME-bitcoin
LND=$NAME-lnd
VOLUME=$NAME-data
WALLET=/data/data/chain/bitcoin/regtest/walletunlock.json
cleanup() {
docker rm -fv "$LND" "$BTC" >/dev/null 2>&1 || true
docker volume rm "$VOLUME" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_for() {
for ((i=0; i<120; i++)); do
if "$@" >/dev/null 2>&1; then return 0; fi
sleep 1
done
docker logs --tail 50 "$LND" >&2 || true
echo "Timed out: $*" >&2
return 1
}
bitcoin() { docker exec "$BTC" bitcoin-cli -regtest -rpcuser=test -rpcpassword=test "$@"; }
address() { docker inspect "$LND" | jq -r --arg n "$NAME" '.[0].NetworkSettings.Networks[$n].IPAddress'; }
info() {
curl -sf --max-time 5 -H "Grpc-Metadata-macaroon:$(docker exec "$LND" xxd -p -c 10000 /data/admin.macaroon)" "$URL/v1/getinfo"
}
saved() { docker exec "$LND" cat "$WALLET"; }
ready() { saved | jq -e '.wallet_password != "hellorockstar" and (has("wallet_password_pending") | not)' && info; }

docker network create --internal "$NAME" >/dev/null
docker run -d --name "$BTC" --network "$NAME" --network-alias bitcoin \
--entrypoint bitcoind btcpayserver/bitcoin:31.1 -regtest -server \
-rpcuser=test -rpcpassword=test -rpcbind=0.0.0.0:18443 -rpcallowip=0.0.0.0/0 \
-listen=0 -zmqpubrawblock=tcp://0.0.0.0:28332 -zmqpubrawtx=tcp://0.0.0.0:28333 >/dev/null
wait_for bitcoin getblockchaininfo
bitcoin createwallet test >/dev/null
bitcoin generatetoaddress 101 "$(bitcoin getnewaddress)" >/dev/null
CONFIG='bitcoin.active=1
bitcoin.regtest=1
bitcoin.node=bitcoind
bitcoind.rpchost=bitcoin:18443
bitcoind.rpcuser=test
bitcoind.rpcpass=test
bitcoind.zmqpubrawblock=tcp://bitcoin:28332
bitcoind.zmqpubrawtx=tcp://bitcoin:28333
restlisten=lnd:8080
no-rest-tls=1
adminmacaroonpath=/data/admin.macaroon
readonlymacaroonpath=/data/readonly.macaroon
invoicemacaroonpath=/data/invoice.macaroon'

for SCENARIO in legacy newline interrupted; do
LEGACY=hellorockstar
if [[ "$SCENARIO" == newline ]]; then LEGACY=$'hellorockstar\n'; fi
docker volume create "$VOLUME" >/dev/null
docker run --rm -i -v "$VOLUME:/data" --entrypoint sh "$IMAGE" -c 'cat > /data/lnd.conf' <<< "$CONFIG"
docker run -d --name "$LND" --network "$NAME" --network-alias lnd \
-v "$VOLUME:/data" --entrypoint lnd "$IMAGE" --lnddir=/data >/dev/null
URL=http://$(address):8080
wait_for curl -sf --max-time 5 "$URL/v1/genseed"
SEED=$(curl -sf "$URL/v1/genseed" | jq '.cipher_seed_mnemonic')
jq -cn --arg pw "$(printf %s "$LEGACY" | base64 | tr -d '\n')" --argjson seed "$SEED" \
'{wallet_password:$pw,cipher_seed_mnemonic:$seed}' | curl -sf -d @- "$URL/v1/initwallet" >/dev/null
wait_for info
IDENTITY=$(info | jq -r .identity_pubkey)
OLD_MACAROON=$(docker exec "$LND" xxd -p -c 10000 /data/admin.macaroon)
BINARY=$(docker exec "$LND" sha256sum /bin/lnd)
jq -cn --argjson seed "$SEED" --arg scenario "$SCENARIO" \
'{wallet_password:"hellorockstar",cipher_seed_mnemonic:$seed} +
(if $scenario == "interrupted" then {wallet_password_pending:"saved-before-the-request"} else {} end)' \
| docker exec -i "$LND" sh -c "cat > $WALLET"
docker stop "$LND" >/dev/null
docker rm "$LND" >/dev/null

# Exercise password migration AND root-key rotation in the same startup.
docker run -d --name "$LND" --network "$NAME" --network-alias lnd --restart unless-stopped \
-v "$VOLUME:/data" -v "$ROOT/docker-entrypoint.sh:/docker-entrypoint.sh:ro" \
-v "$ROOT/docker-initunlocklnd.sh:/docker-initunlocklnd.sh:ro" \
-e LND_CHAIN=btc -e LND_ENVIRONMENT=regtest -e "LND_EXTRA_ARGS=$CONFIG" \
-e LND_REST_LISTEN_HOST=http://lnd:8080 -e LND_MACAROON_ROTATION_ID=test "$IMAGE" >/dev/null
URL=http://$(address):8080
wait_for ready
SAVED=$(saved)
[[ $(info | jq -r .identity_pubkey) == "$IDENTITY" ]]
[[ $(saved | jq '.cipher_seed_mnemonic') == "$SEED" ]]
[[ $(docker exec "$LND" sha256sum /bin/lnd) == "$BINARY" ]]
[[ $(curl -s -o /dev/null -w '%{http_code}' -H "Grpc-Metadata-macaroon:$OLD_MACAROON" "$URL/v1/getinfo") != 200 ]]
docker logs "$LND" 2>&1 | grep 'default root key not found' >/dev/null
docker restart "$LND" >/dev/null
wait_for info
[[ $(saved) == "$SAVED" ]]
docker rm -fv "$LND" >/dev/null
docker volume rm "$VOLUME" >/dev/null
done
echo 'PASS: rotation, both legacy passwords, interrupted migration and subsequent restart'
Loading