backup: add encrypted L402 static address backups

This commit is contained in:
Slyghtning 2026-07-09 14:54:27 +02:00
parent c9c80b212d
commit 800d1837ee
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
9 changed files with 1526 additions and 9 deletions

692
backup/service.go Normal file
View file

@ -0,0 +1,692 @@
package backup
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/lightninglabs/aperture/l402"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/staticaddr/address"
staticaddrscript "github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes"
"golang.org/x/crypto/nacl/secretbox"
"gopkg.in/macaroon.v2"
)
const (
backupVersion = 1
backupBaseName = "L402_backup"
backupFileExt = ".enc"
paidTokenFileName = "l402.token"
)
// backupKeyLocator identifies the lnd key used only for deriving the local
// backup encryption key. The encrypted backup stays tied to the same lnd seed
// material without adding a separate user-managed password.
var backupKeyLocator = keychain.KeyLocator{
Family: keychain.KeyFamily(swap.StaticAddressKeyFamily),
Index: 0,
}
// backupMagic prefixes encrypted backup files so corrupt or unrelated files can
// be rejected before attempting to unmarshal JSON payloads.
var backupMagic = []byte("loopbak1")
// StaticAddressManager is the subset of static-address behavior required for
// creating backups.
type StaticAddressManager interface {
// GetStaticAddressParameters returns the concrete legacy static address
// row that is paired with the current paid L402 generation.
GetStaticAddressParameters(context.Context) (
*staticaddrscript.Parameters, error)
// CurrentHeight returns the manager's current chain height, which is
// stored as the future multi-address scan floor for this generation.
CurrentHeight() int32
}
// Service creates encrypted local backups for Loop static-address and L402
// state.
type Service struct {
dataDir string
network string
signer lndclient.SignerClient
staticAddressManager StaticAddressManager
}
type backupPayload struct {
Version uint32 `json:"version"`
Network string `json:"network"`
L402TokenID string `json:"l402_token_id"`
L402TokenCreatedAt int64 `json:"l402_token_created_at"`
StaticAddress *staticAddressBackup `json:"static_address,omitempty"`
TokenFiles []*l402TokenFileEntry `json:"token_files,omitempty"`
}
// staticAddressBackup contains the legacy single-address data that can be
// restored directly by future recovery code, plus the stable per-L402
// multi-address/change branch fields future multi-address recovery will scan
// from.
type staticAddressBackup struct {
ProtocolVersion uint32 `json:"protocol_version"`
ClientPubKey []byte `json:"client_pubkey,omitempty"`
ServerPubKey []byte `json:"server_pubkey"`
Expiry uint32 `json:"expiry"`
LegacyClientKeyFamily int32 `json:"legacy_client_key_family,omitempty"`
MainKeyFamily int32 `json:"main_key_family"`
ChangeKeyFamily int32 `json:"change_key_family"`
LegacyFirstHeight int32 `json:"legacy_first_height,omitempty"`
MultiAddressFirstHeight int32 `json:"multi_address_first_height,omitempty"`
}
type l402TokenFileEntry struct {
Name string `json:"name"`
Data []byte `json:"data"`
}
type currentTokenState struct {
TokenID string
TokenCreatedAt int64
TokenFiles []*l402TokenFileEntry
}
type paidTokenMetadata struct {
tokenID string
tokenCreatedAt int64
}
type backupFileDetails struct {
tokenID string
titleTimestamp int64
}
// NewService constructs a backup service for a specific loop network data
// directory.
func NewService(dataDir, network string, signer lndclient.SignerClient,
staticAddressManager StaticAddressManager) *Service {
return &Service{
dataDir: dataDir,
network: network,
signer: signer,
staticAddressManager: staticAddressManager,
}
}
// WriteBackup writes an encrypted backup file for the current paid-L402 /
// static-address generation. It returns an empty path when there is no complete
// generation yet, or when the current L402 already has an immutable backup on
// disk.
func (s *Service) WriteBackup(ctx context.Context) (string, error) {
// A backup is immutable and generation-based, so first collect enough
// state to prove the current generation is complete: one paid L402 token
// plus one concrete static address bound to that token.
payload, hasState, err := s.buildPayload(ctx)
if err != nil || !hasState {
return "", err
}
// We need the derived key before checking for existing backups because a
// filename match alone is not enough. A stale or corrupt file with the same
// token ID must not suppress writing a valid backup.
key, err := s.deriveEncryptionKey(ctx)
if err != nil {
return "", err
}
// If a valid backup for the exact token creation time already exists, the
// generation is already protected and must not be rewritten.
if backupFile, err := findValidBackupFileForToken(
s.dataDir, key, s.network, payload.L402TokenID,
payload.L402TokenCreatedAt,
); err != nil {
return "", err
} else if backupFile != "" {
return "", nil
}
fileName := backupFilePath(
s.dataDir, payload.L402TokenID, payload.L402TokenCreatedAt,
)
// The plaintext is never written to disk. It is marshaled in memory,
// encrypted with the lnd-derived key, then atomically installed.
plaintext, err := json.Marshal(payload)
if err != nil {
return "", err
}
encrypted, err := encryptBackupPayload(key, plaintext)
if err != nil {
return "", err
}
err = writeFileAtomically(fileName, encrypted)
if err != nil {
return "", err
}
return fileName, nil
}
func (p *backupPayload) validateNetwork(currentNetwork string) error {
switch {
case p.Version != backupVersion:
return fmt.Errorf("unsupported backup version %d", p.Version)
case p.Network == "":
return fmt.Errorf("backup file is missing a network")
case p.L402TokenID == "":
return fmt.Errorf("backup file is missing an L402 token ID")
case p.Network != currentNetwork:
return fmt.Errorf("backup file network %s does not match "+
"daemon network %s", p.Network, currentNetwork)
}
return nil
}
func (p *backupPayload) validateCompleteGeneration(
fileDetails *backupFileDetails) error {
// When the caller knows the filename metadata, require it to match the
// payload. This keeps the immutable filename and encrypted contents bound
// to the same L402 generation.
if fileDetails != nil {
if p.L402TokenID != fileDetails.tokenID {
return fmt.Errorf("backup file token ID %s does not match "+
"payload token ID %s", fileDetails.tokenID,
p.L402TokenID)
}
if p.L402TokenCreatedAt != fileDetails.titleTimestamp {
return fmt.Errorf("backup file timestamp %d does not "+
"match payload L402 creation time %d",
fileDetails.titleTimestamp, p.L402TokenCreatedAt)
}
}
if len(p.TokenFiles) == 0 {
return fmt.Errorf("backup file is missing paid L402 token data")
}
if p.StaticAddress == nil {
return fmt.Errorf("backup file is missing static address " +
"parameters")
}
// The raw token file is the source of truth for the paid L402. Decode its
// metadata and make sure it matches the generation named by the payload.
metadata, err := validatePaidTokenFiles(p.TokenFiles)
if err != nil {
return err
}
if metadata.tokenID != p.L402TokenID {
return fmt.Errorf("backup L402 token ID %s does not match "+
"payload token ID %s", metadata.tokenID, p.L402TokenID)
}
if metadata.tokenCreatedAt != p.L402TokenCreatedAt {
return fmt.Errorf("backup L402 token creation time %d does "+
"not match payload creation time %d",
metadata.tokenCreatedAt, p.L402TokenCreatedAt)
}
return nil
}
func (s *Service) buildPayload(ctx context.Context) (*backupPayload, bool,
error) {
// Backups are only meaningful after the token payment completed. Pending
// L402 tokens can still change and do not define an immutable generation.
tokenState, err := s.currentPaidToken()
if err != nil {
return nil, false, err
}
if tokenState == nil || s.staticAddressManager == nil {
return nil, false, nil
}
payload := &backupPayload{
Version: backupVersion,
Network: s.network,
L402TokenID: tokenState.TokenID,
L402TokenCreatedAt: tokenState.TokenCreatedAt,
TokenFiles: tokenState.TokenFiles,
}
// The current static-address row supplies the legacy concrete address. The
// same payload also stores the deterministic families and scan floor future
// multi-address recovery will use without rewriting this backup.
addrParams, err := s.staticAddressManager.GetStaticAddressParameters(ctx)
switch {
case err == nil:
multiAddressFirstHeight := s.staticAddressManager.CurrentHeight()
if multiAddressFirstHeight <= 0 {
return nil, false, fmt.Errorf(
"invalid multi-address first height %d",
multiAddressFirstHeight,
)
}
payload.StaticAddress = &staticAddressBackup{
ProtocolVersion: uint32(addrParams.ProtocolVersion),
ClientPubKey: addrParams.ClientPubkey.
SerializeCompressed(),
ServerPubKey: addrParams.ServerPubkey.
SerializeCompressed(),
Expiry: addrParams.Expiry,
LegacyClientKeyFamily: int32(
addrParams.KeyLocator.Family,
),
MainKeyFamily: swap.StaticMultiAddressKeyFamily,
ChangeKeyFamily: swap.StaticAddressChangeKeyFamily,
LegacyFirstHeight: addrParams.InitiationHeight,
MultiAddressFirstHeight: multiAddressFirstHeight,
}
case errors.Is(err, address.ErrNoStaticAddress):
// The current L402 does not have a complete static-address generation
// yet, so there is nothing immutable to back up.
return nil, false, nil
default:
return nil, false, err
}
hasState := payload.StaticAddress != nil && len(payload.TokenFiles) > 0
return payload, hasState, nil
}
func (s *Service) currentPaidToken() (*currentTokenState, error) {
tokenStore, err := l402.NewFileStore(s.dataDir)
if err != nil {
return nil, err
}
token, err := tokenStore.CurrentToken()
switch {
case err == nil:
case errors.Is(err, l402.ErrNoToken):
return nil, nil
default:
return nil, err
}
// Only fully paid tokens define an immutable generation.
if token.Preimage == (lntypes.Preimage{}) {
return nil, nil
}
// Preserve the exact token file bytes instead of reserializing the token.
// That keeps future restore code compatible with Aperture's token-store
// format.
tokenID, err := decodeTokenID(token)
if err != nil {
return nil, err
}
path := filepath.Join(s.dataDir, paidTokenFileName)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
return &currentTokenState{
TokenID: tokenID,
TokenCreatedAt: token.TimeCreated.UnixNano(),
TokenFiles: []*l402TokenFileEntry{{
Name: paidTokenFileName,
Data: data,
}},
}, nil
}
func decodeTokenID(token *l402.Token) (string, error) {
identifier, err := l402.DecodeIdentifier(
bytes.NewReader(token.BaseMacaroon().Id()),
)
if err != nil {
return "", err
}
return identifier.TokenID.String(), nil
}
func backupFilePath(dataDir, tokenID string, tokenCreatedAt int64) string {
return filepath.Join(dataDir, backupFileName(tokenID, tokenCreatedAt))
}
func backupFileName(tokenID string, tokenCreatedAt int64) string {
return fmt.Sprintf(
"%s_%019d_%s%s", backupBaseName, tokenCreatedAt, tokenID,
backupFileExt,
)
}
func backupFileTokenID(name string) (string, bool) {
details, ok := parseBackupFileName(name)
if !ok {
return "", false
}
return details.tokenID, true
}
func parseBackupFileName(name string) (*backupFileDetails, bool) {
if !strings.HasPrefix(name, backupBaseName+"_") ||
!strings.HasSuffix(name, backupFileExt) {
return nil, false
}
remainder := strings.TrimSuffix(
strings.TrimPrefix(name, backupBaseName+"_"), backupFileExt,
)
parts := strings.SplitN(remainder, "_", 2)
if len(parts) != 2 {
return nil, false
}
titleTimestamp, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, false
}
tokenID := parts[1]
_, err = l402.MakeIDFromString(tokenID)
if err != nil {
return nil, false
}
return &backupFileDetails{
tokenID: tokenID,
titleTimestamp: titleTimestamp,
}, true
}
func findValidBackupFileForToken(dataDir string, key [32]byte, network,
tokenID string, tokenCreatedAt int64) (string, error) {
dirEntries, err := os.ReadDir(dataDir)
if err != nil {
return "", err
}
for _, entry := range dirEntries {
if entry.IsDir() {
continue
}
// Search by token ID first, then decrypt to verify the candidate is a
// valid backup for this exact paid-token generation.
details, ok := parseBackupFileName(entry.Name())
if !ok || details.tokenID != tokenID {
continue
}
path := filepath.Join(dataDir, entry.Name())
payload, err := readBackupPayload(key, path)
if err != nil {
// Invalid same-token files are ignored so WriteBackup can replace a
// corrupt placeholder with a real backup.
continue
}
err = payload.validateNetwork(network)
if err != nil {
continue
}
err = payload.validateCompleteGeneration(details)
if err != nil {
continue
}
if payload.L402TokenCreatedAt != tokenCreatedAt {
continue
}
return path, nil
}
return "", nil
}
func readBackupPayload(key [32]byte, path string) (*backupPayload, error) {
ciphertext, err := os.ReadFile(path)
if err != nil {
return nil, err
}
plaintext, err := decryptBackupPayload(key, ciphertext)
if err != nil {
return nil, err
}
var payload backupPayload
err = json.Unmarshal(plaintext, &payload)
if err != nil {
return nil, err
}
return &payload, nil
}
func validatePaidTokenFiles(
backupFiles []*l402TokenFileEntry) (*paidTokenMetadata, error) {
var paidTokenData []byte
for _, file := range backupFiles {
if !isTokenFileName(file.Name) {
return nil, fmt.Errorf("unexpected token file name %q",
file.Name)
}
if paidTokenData != nil {
return nil, fmt.Errorf("backup contains duplicate paid " +
"L402 token data")
}
paidTokenData = file.Data
}
if paidTokenData == nil {
return nil, fmt.Errorf("backup file is missing paid L402 token data")
}
return parsePaidTokenMetadata(paidTokenData)
}
func parsePaidTokenMetadata(data []byte) (*paidTokenMetadata, error) {
r := bytes.NewReader(data)
var macLen uint32
err := binary.Read(r, binary.BigEndian, &macLen)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token macaroon "+
"length: %w", err)
}
if uint64(macLen) > uint64(r.Len()) {
return nil, fmt.Errorf("invalid L402 token macaroon length")
}
macBytes := make([]byte, macLen)
err = binary.Read(r, binary.BigEndian, &macBytes)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token macaroon: %w",
err)
}
var paymentHash lntypes.Hash
err = binary.Read(r, binary.BigEndian, &paymentHash)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token payment hash: %w",
err)
}
var preimage lntypes.Preimage
err = binary.Read(r, binary.BigEndian, &preimage)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token preimage: %w",
err)
}
if preimage == (lntypes.Preimage{}) {
return nil, fmt.Errorf("backup L402 token is not paid")
}
var amountPaid uint64
err = binary.Read(r, binary.BigEndian, &amountPaid)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token amount: %w", err)
}
var routingFeePaid uint64
err = binary.Read(r, binary.BigEndian, &routingFeePaid)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token routing fee: %w",
err)
}
var tokenCreatedAt int64
err = binary.Read(r, binary.BigEndian, &tokenCreatedAt)
if err != nil {
return nil, fmt.Errorf("unable to read L402 token creation time: %w",
err)
}
mac := &macaroon.Macaroon{}
err = mac.UnmarshalBinary(macBytes)
if err != nil {
return nil, fmt.Errorf("unable to unmarshal L402 token "+
"macaroon: %w", err)
}
identifier, err := l402.DecodeIdentifier(bytes.NewReader(mac.Id()))
if err != nil {
return nil, fmt.Errorf("unable to decode L402 token ID: %w", err)
}
return &paidTokenMetadata{
tokenID: identifier.TokenID.String(),
tokenCreatedAt: tokenCreatedAt,
}, nil
}
func (s *Service) deriveEncryptionKey(ctx context.Context) ([32]byte, error) {
return s.signer.DeriveSharedKey(
ctx, lndclient.SharedKeyNUMS, &backupKeyLocator,
)
}
func encryptBackupPayload(key [32]byte, plaintext []byte) ([]byte, error) {
var nonce [24]byte
_, err := rand.Read(nonce[:])
if err != nil {
return nil, err
}
cipherText := secretbox.Seal(nil, plaintext, &nonce, &key)
encoded := make([]byte, 0, len(backupMagic)+len(nonce)+len(cipherText))
encoded = append(encoded, backupMagic...)
encoded = append(encoded, nonce[:]...)
encoded = append(encoded, cipherText...)
return encoded, nil
}
func decryptBackupPayload(key [32]byte, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < len(backupMagic)+24 {
return nil, fmt.Errorf("backup file is too short")
}
if !bytes.Equal(ciphertext[:len(backupMagic)], backupMagic) {
return nil, fmt.Errorf("backup file has an unknown format")
}
var nonce [24]byte
copy(nonce[:], ciphertext[len(backupMagic):len(backupMagic)+24])
plaintext, ok := secretbox.Open(
nil, ciphertext[len(backupMagic)+24:], &nonce, &key,
)
if !ok {
return nil, fmt.Errorf("unable to decrypt backup file")
}
return plaintext, nil
}
func writeFileAtomically(path string, data []byte) error {
tempPath := path + ".tmp"
// Write private files through a temp path so a crash cannot leave a
// partially written backup at the final name.
file, err := os.OpenFile(
tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600,
)
if err != nil {
return err
}
_, err = file.Write(data)
if err != nil {
_ = file.Close()
_ = os.Remove(tempPath)
return err
}
err = file.Sync()
if err != nil {
_ = file.Close()
_ = os.Remove(tempPath)
return err
}
err = file.Close()
if err != nil {
_ = os.Remove(tempPath)
return err
}
err = os.Rename(tempPath, path)
if err != nil {
_ = os.Remove(tempPath)
}
return err
}
func isTokenFileName(name string) bool {
return filepath.Base(name) == name && name == paidTokenFileName
}

762
backup/service_test.go Normal file
View file

@ -0,0 +1,762 @@
package backup
import (
"bytes"
"context"
"encoding/binary"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/lightninglabs/aperture/l402"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/staticaddr/address"
staticaddrscript "github.com/lightninglabs/loop/staticaddr/script"
staticaddrversion "github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/swap"
testutils "github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
"gopkg.in/macaroon.v2"
)
// TestEncryptDecryptBackupPayload verifies that a backup payload round-trips
// through the secretbox envelope and is not stored as plaintext.
func TestEncryptDecryptBackupPayload(t *testing.T) {
t.Parallel()
var key [32]byte
copy(key[:], []byte("0123456789abcdefghijklmnopqrstuv"))
plaintext := []byte("loop backup payload")
encrypted, err := encryptBackupPayload(key, plaintext)
require.NoError(t, err)
require.NotEqual(t, plaintext, encrypted)
decrypted, err := decryptBackupPayload(key, encrypted)
require.NoError(t, err)
require.Equal(t, plaintext, decrypted)
}
// TestBackupEncryptionUsesSignerDerivedKey verifies that backups are encrypted
// with the documented lnd-derived key.
func TestBackupEncryptionUsesSignerDerivedKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
signer := &fixedKeySigner{
key: testBackupKey(1),
}
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
writePaidToken(
t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC),
)
svc := NewService(
dir, "testnet", signer,
&mockStaticAddressManager{
params: addrParams,
},
)
backupFile, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Len(t, signer.calls, 1)
require.True(t, signer.calls[0].pubKey.IsEqual(lndclient.SharedKeyNUMS))
require.Equal(t, backupKeyLocator, *signer.calls[0].locator)
_, err = readBackupPayload(testBackupKey(2), backupFile)
require.ErrorContains(t, err, "unable to decrypt backup file")
payload, err := readBackupPayload(testBackupKey(1), backupFile)
require.NoError(t, err)
require.EqualValues(t, backupVersion, payload.Version)
}
// TestWriteBackupReturnsEmptyWithoutState verifies that no backup is written
// before Loop has both paid L402 state and static-address state.
func TestWriteBackupReturnsEmptyWithoutState(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
svc := NewService(dir, "testnet", lnd.Signer, nil)
backupFile, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Empty(t, backupFile)
require.Empty(t, listBackupFiles(t, dir))
}
// TestWriteBackupReturnsEmptyWithTokenOnly verifies that a paid L402 by itself
// does not define a complete static-address generation backup.
func TestWriteBackupReturnsEmptyWithTokenOnly(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
writePaidToken(
t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC),
)
svc := NewService(dir, "testnet", lnd.Signer, nil)
backupFile, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Empty(t, backupFile)
require.Empty(t, listBackupFiles(t, dir))
}
// TestWriteBackupReturnsEmptyWithPendingToken verifies that pending L402 token
// material is not backed up as an immutable generation.
func TestWriteBackupReturnsEmptyWithPendingToken(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
writePendingToken(
t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC),
)
svc := NewService(
dir, "testnet", lnd.Signer,
&mockStaticAddressManager{
params: addrParams,
},
)
backupFile, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Empty(t, backupFile)
require.Empty(t, listBackupFiles(t, dir))
}
// TestWriteBackupIncludesStaticAddressAndPaidToken verifies that a complete
// generation backup contains the expected static-address parameters, exact paid
// L402 token bytes and private file permissions.
func TestWriteBackupIncludesStaticAddressAndPaidToken(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
staticMgr := &mockStaticAddressManager{
params: addrParams,
currentHeight: 654,
}
tokenCreatedAt := time.Date(
2026, time.April, 14, 9, 30, 1, 123, time.UTC,
)
tokenID := writePaidToken(t, dir, 1, tokenCreatedAt)
svc := NewService(dir, "testnet", lnd.Signer, staticMgr)
backupFile, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Equal(
t, backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()),
backupFile,
)
key, err := svc.deriveEncryptionKey(context.Background())
require.NoError(t, err)
payload, err := readBackupPayload(key, backupFile)
require.NoError(t, err)
originalToken, err := os.ReadFile(filepath.Join(dir, paidTokenFileName))
require.NoError(t, err)
require.EqualValues(t, backupVersion, payload.Version)
require.Equal(t, "testnet", payload.Network)
require.Equal(t, tokenID, payload.L402TokenID)
require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt)
require.NotNil(t, payload.StaticAddress)
require.EqualValues(
t, addrParams.ProtocolVersion, payload.StaticAddress.ProtocolVersion,
)
require.Equal(
t, addrParams.ClientPubkey.SerializeCompressed(),
payload.StaticAddress.ClientPubKey,
)
require.Equal(
t, addrParams.ServerPubkey.SerializeCompressed(),
payload.StaticAddress.ServerPubKey,
)
require.Equal(t, addrParams.Expiry, payload.StaticAddress.Expiry)
require.Equal(
t, int32(addrParams.KeyLocator.Family),
payload.StaticAddress.LegacyClientKeyFamily,
)
require.Equal(
t, swap.StaticMultiAddressKeyFamily,
payload.StaticAddress.MainKeyFamily,
)
require.Equal(
t, swap.StaticAddressChangeKeyFamily,
payload.StaticAddress.ChangeKeyFamily,
)
require.NotEqual(
t, payload.StaticAddress.LegacyClientKeyFamily,
payload.StaticAddress.MainKeyFamily,
)
require.NotEqual(
t, payload.StaticAddress.LegacyClientKeyFamily,
payload.StaticAddress.ChangeKeyFamily,
)
require.NotEqual(
t, payload.StaticAddress.MainKeyFamily,
payload.StaticAddress.ChangeKeyFamily,
)
require.Equal(
t, addrParams.InitiationHeight,
payload.StaticAddress.LegacyFirstHeight,
)
require.Equal(
t, int32(654),
payload.StaticAddress.MultiAddressFirstHeight,
)
require.Len(t, payload.TokenFiles, 1)
require.Equal(t, paidTokenFileName, payload.TokenFiles[0].Name)
require.Equal(t, originalToken, payload.TokenFiles[0].Data)
info, err := os.Stat(backupFile)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
// TestStaticAddressBackupReconstructsLegacyStaticAddress verifies that the
// backed-up legacy client key material reconstructs the original static address
// tapscript and taproot address.
func TestStaticAddressBackupReconstructsLegacyStaticAddress(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
staticMgr := &mockStaticAddressManager{
params: addrParams,
}
writePaidToken(
t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC),
)
svc := NewService(dir, "testnet", lnd.Signer, staticMgr)
backupFile, err := svc.WriteBackup(ctx)
require.NoError(t, err)
key, err := svc.deriveEncryptionKey(ctx)
require.NoError(t, err)
payload, err := readBackupPayload(key, backupFile)
require.NoError(t, err)
require.NotNil(t, payload.StaticAddress)
clientPubKey, err := btcec.ParsePubKey(
payload.StaticAddress.ClientPubKey,
)
require.NoError(t, err)
serverPubKey, err := btcec.ParsePubKey(
payload.StaticAddress.ServerPubKey,
)
require.NoError(t, err)
reconstructed, err := staticaddrscript.NewStaticAddress(
input.MuSig2Version100RC2,
int64(payload.StaticAddress.Expiry), clientPubKey, serverPubKey,
)
require.NoError(t, err)
pkScript, err := reconstructed.StaticAddressScript()
require.NoError(t, err)
require.Equal(t, addrParams.PkScript, pkScript)
expectedAddr, err := taprootAddress(
addrParams.ClientPubkey, addrParams.ServerPubkey,
int64(addrParams.Expiry), lnd.ChainParams,
)
require.NoError(t, err)
reconstructedAddr, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(reconstructed.TaprootKey), lnd.ChainParams,
)
require.NoError(t, err)
require.Equal(t, expectedAddr.String(), reconstructedAddr.String())
}
// TestStaticAddressBackupReconstructsChangeStaticAddress verifies that the
// backed-up change key family can reconstruct the change static address and
// that it is distinct from the legacy main static address.
func TestStaticAddressBackupReconstructsChangeStaticAddress(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
staticMgr := &mockStaticAddressManager{
params: addrParams,
}
expectedChangeKey, err := lnd.WalletKit.DeriveKey(
ctx, &keychain.KeyLocator{
Family: keychain.KeyFamily(swap.StaticAddressChangeKeyFamily),
Index: 0,
},
)
require.NoError(t, err)
expectedChangeStaticAddr, err := staticaddrscript.NewStaticAddress(
input.MuSig2Version100RC2,
int64(addrParams.Expiry), expectedChangeKey.PubKey,
addrParams.ServerPubkey,
)
require.NoError(t, err)
expectedChangePkScript, err := expectedChangeStaticAddr.StaticAddressScript()
require.NoError(t, err)
expectedChangeAddr, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(expectedChangeStaticAddr.TaprootKey),
lnd.ChainParams,
)
require.NoError(t, err)
writePaidToken(
t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC),
)
svc := NewService(dir, "testnet", lnd.Signer, staticMgr)
backupFile, err := svc.WriteBackup(ctx)
require.NoError(t, err)
key, err := svc.deriveEncryptionKey(ctx)
require.NoError(t, err)
payload, err := readBackupPayload(key, backupFile)
require.NoError(t, err)
require.NotNil(t, payload.StaticAddress)
serverPubKey, err := btcec.ParsePubKey(
payload.StaticAddress.ServerPubKey,
)
require.NoError(t, err)
changeKeyDesc, err := lnd.WalletKit.DeriveKey(
ctx, &keychain.KeyLocator{
Family: keychain.KeyFamily(
payload.StaticAddress.ChangeKeyFamily,
),
Index: 0,
},
)
require.NoError(t, err)
reconstructed, err := staticaddrscript.NewStaticAddress(
input.MuSig2Version100RC2,
int64(payload.StaticAddress.Expiry), changeKeyDesc.PubKey,
serverPubKey,
)
require.NoError(t, err)
pkScript, err := reconstructed.StaticAddressScript()
require.NoError(t, err)
require.Equal(t, expectedChangePkScript, pkScript)
reconstructedAddr, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(reconstructed.TaprootKey), lnd.ChainParams,
)
require.NoError(t, err)
require.Equal(t, expectedChangeAddr.String(), reconstructedAddr.String())
legacyAddr, err := taprootAddress(
addrParams.ClientPubkey, addrParams.ServerPubkey,
int64(addrParams.Expiry), lnd.ChainParams,
)
require.NoError(t, err)
require.NotEqual(t, legacyAddr.String(), reconstructedAddr.String())
}
// TestWriteBackupIsImmutablePerL402 verifies that an existing backup for the
// active L402 token prevents rewriting or creating another backup for the same
// generation.
func TestWriteBackupIsImmutablePerL402(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
staticMgr := &mockStaticAddressManager{
params: addrParams,
}
tokenCreatedAt := time.Date(
2026, time.April, 14, 9, 30, 1, 0, time.UTC,
)
tokenID := writePaidToken(t, dir, 2, tokenCreatedAt)
svc := NewService(dir, "testnet", lnd.Signer, staticMgr)
firstBackup, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Equal(
t, backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()),
firstBackup,
)
secondBackup, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Empty(t, secondBackup)
require.Equal(t, []string{firstBackup}, listBackupFiles(t, dir))
}
// TestWriteBackupIgnoresInvalidSameTokenBackup verifies that a corrupt file
// with the active token ID in its name does not suppress creation of a valid
// backup.
func TestWriteBackupIgnoresInvalidSameTokenBackup(t *testing.T) {
t.Parallel()
dir := t.TempDir()
lnd := testutils.NewMockLnd()
addrParams := makeStaticAddressParams(
t, lnd, 7, defaultBackupServerPubkey, 144, 321,
)
staticMgr := &mockStaticAddressManager{
params: addrParams,
}
tokenCreatedAt := time.Date(
2026, time.April, 14, 9, 30, 1, 0, time.UTC,
)
tokenID := writePaidToken(t, dir, 3, tokenCreatedAt)
backupPath := backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano())
err := os.WriteFile(backupPath, []byte("corrupt backup"), 0600)
require.NoError(t, err)
svc := NewService(dir, "testnet", lnd.Signer, staticMgr)
writtenBackup, err := svc.WriteBackup(context.Background())
require.NoError(t, err)
require.Equal(t, backupPath, writtenBackup)
key, err := svc.deriveEncryptionKey(context.Background())
require.NoError(t, err)
payload, err := readBackupPayload(key, backupPath)
require.NoError(t, err)
require.Equal(t, tokenID, payload.L402TokenID)
require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt)
}
// TestWriteFileAtomically verifies that backup files are written with private
// permissions and that failed atomic writes clean up their temporary files.
func TestWriteFileAtomically(t *testing.T) {
t.Parallel()
t.Run("uses private permissions", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "backup.enc")
err := writeFileAtomically(path, []byte("backup"))
require.NoError(t, err)
info, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
})
t.Run("cleans temp file on rename error", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "backup-target")
err := os.Mkdir(path, 0700)
require.NoError(t, err)
err = writeFileAtomically(path, []byte("backup"))
require.Error(t, err)
_, err = os.Stat(path + ".tmp")
require.ErrorIs(t, err, os.ErrNotExist)
})
}
var defaultBackupServerPubkey = func() *btcec.PublicKey {
_, pubKey := testutils.CreateKey(42)
return pubKey
}()
type deriveSharedKeyCall struct {
pubKey *btcec.PublicKey
locator *keychain.KeyLocator
}
type fixedKeySigner struct {
lndclient.SignerClient
key [32]byte
calls []deriveSharedKeyCall
}
func (s *fixedKeySigner) DeriveSharedKey(_ context.Context,
pubKey *btcec.PublicKey, locator *keychain.KeyLocator) ([32]byte,
error) {
call := deriveSharedKeyCall{
pubKey: pubKey,
}
if locator != nil {
locatorCopy := *locator
call.locator = &locatorCopy
}
s.calls = append(s.calls, call)
return s.key, nil
}
func testBackupKey(seed byte) [32]byte {
var key [32]byte
for idx := range key {
key[idx] = seed
}
return key
}
type mockStaticAddressManager struct {
params *staticaddrscript.Parameters
currentHeight int32
getParamsErr error
}
func (m *mockStaticAddressManager) GetStaticAddressParameters(
context.Context) (*staticaddrscript.Parameters, error) {
switch {
case m.getParamsErr != nil:
return nil, m.getParamsErr
case m.params == nil:
return nil, address.ErrNoStaticAddress
default:
return cloneAddressParameters(m.params), nil
}
}
func (m *mockStaticAddressManager) CurrentHeight() int32 {
if m.currentHeight > 0 {
return m.currentHeight
}
if m.params != nil {
return m.params.InitiationHeight
}
return 0
}
func makeStaticAddressParams(t *testing.T, lnd *testutils.LndMockServices,
index uint32, serverPubKey *btcec.PublicKey, expiry uint32,
initiationHeight int32) *staticaddrscript.Parameters {
t.Helper()
keyDesc, err := lnd.WalletKit.DeriveKey(
context.Background(), &keychain.KeyLocator{
Family: keychain.KeyFamily(swap.StaticAddressKeyFamily),
Index: index,
},
)
require.NoError(t, err)
staticAddress, err := staticaddrscript.NewStaticAddress(
input.MuSig2Version100RC2, int64(expiry), keyDesc.PubKey,
serverPubKey,
)
require.NoError(t, err)
pkScript, err := staticAddress.StaticAddressScript()
require.NoError(t, err)
return &staticaddrscript.Parameters{
ClientPubkey: keyDesc.PubKey,
ServerPubkey: serverPubKey,
Expiry: expiry,
PkScript: pkScript,
KeyLocator: keyDesc.KeyLocator,
ProtocolVersion: staticaddrversion.ProtocolVersion_V0,
InitiationHeight: initiationHeight,
}
}
func cloneAddressParameters(
params *staticaddrscript.Parameters) *staticaddrscript.Parameters {
if params == nil {
return nil
}
return &staticaddrscript.Parameters{
ClientPubkey: params.ClientPubkey,
ServerPubkey: params.ServerPubkey,
Expiry: params.Expiry,
PkScript: slices.Clone(params.PkScript),
KeyLocator: params.KeyLocator,
ProtocolVersion: params.ProtocolVersion,
InitiationHeight: params.InitiationHeight,
}
}
func taprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, expiry int64,
chainParams *chaincfg.Params) (*btcutil.AddressTaproot, error) {
staticAddress, err := staticaddrscript.NewStaticAddress(
input.MuSig2Version100RC2, expiry, clientPubkey, serverPubkey,
)
if err != nil {
return nil, err
}
return btcutil.NewAddressTaproot(
schnorr.SerializePubKey(staticAddress.TaprootKey), chainParams,
)
}
func writePaidToken(t *testing.T, dir string, seed byte,
createdAt time.Time) string {
t.Helper()
return writeTokenFile(
t, filepath.Join(dir, paidTokenFileName), seed, createdAt, true,
)
}
func writePendingToken(t *testing.T, dir string, seed byte,
createdAt time.Time) string {
t.Helper()
return writeTokenFile(
t, filepath.Join(dir, "l402.token.pending"), seed, createdAt, false,
)
}
func writeTokenFile(t *testing.T, path string, seed byte, createdAt time.Time,
paid bool) string {
t.Helper()
var (
paymentHash lntypes.Hash
tokenID l402.TokenID
preimage lntypes.Preimage
)
paymentHash[0] = seed
tokenID[0] = seed
if paid {
preimage[0] = seed
}
data := tokenFileData(
t, tokenID, paymentHash, preimage, seed, createdAt,
)
err := os.WriteFile(path, data, 0600)
require.NoError(t, err)
return tokenID.String()
}
func tokenFileData(t *testing.T, tokenID l402.TokenID,
paymentHash lntypes.Hash, preimage lntypes.Preimage, seed byte,
createdAt time.Time) []byte {
t.Helper()
var idBytes bytes.Buffer
err := l402.EncodeIdentifier(&idBytes, &l402.Identifier{
Version: l402.LatestVersion,
PaymentHash: paymentHash,
TokenID: tokenID,
})
require.NoError(t, err)
mac, err := macaroon.New(
[]byte("loop-backup-test-root-key"),
idBytes.Bytes(), "loop.test", macaroon.LatestVersion,
)
require.NoError(t, err)
macBytes, err := mac.MarshalBinary()
require.NoError(t, err)
var serialized bytes.Buffer
err = binary.Write(&serialized, binary.BigEndian, uint32(len(macBytes)))
require.NoError(t, err)
err = binary.Write(&serialized, binary.BigEndian, macBytes)
require.NoError(t, err)
err = binary.Write(&serialized, binary.BigEndian, paymentHash)
require.NoError(t, err)
err = binary.Write(&serialized, binary.BigEndian, preimage)
require.NoError(t, err)
err = binary.Write(
&serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*1000,
)
require.NoError(t, err)
err = binary.Write(
&serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*10,
)
require.NoError(t, err)
err = binary.Write(&serialized, binary.BigEndian, createdAt.UnixNano())
require.NoError(t, err)
return serialized.Bytes()
}
func listBackupFiles(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
var files []string
for _, entry := range entries {
if _, ok := backupFileTokenID(entry.Name()); ok {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
slices.Sort(files)
return files
}

View file

@ -148,17 +148,17 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error {
return showCommandHelp(ctx, cmd)
}
err := displayNewAddressWarning()
if err != nil {
return err
}
client, cleanup, err := getClient(cmd)
if err != nil {
return err
}
defer cleanup()
err = maybeDisplayNewAddressWarning(ctx, client)
if err != nil {
return err
}
resp, err := client.NewStaticAddress(
ctx, &looprpc.NewStaticAddressRequest{},
)
@ -1134,7 +1134,7 @@ func maybeDisplayNewAddressWarning(ctx context.Context,
}
func displayNewAddressWarning() error {
fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " +
fmt.Printf("\nWARNING: Be aware that losing your l402.token file in " +
".loop under your home directory will take your ability to " +
"spend funds sent to the static address via loop-ins or " +
"withdrawals. You will have to wait until the deposit " +

View file

@ -13,13 +13,36 @@
"clock_start_unix": 1769407086
},
"events": [
{
"time_ms": 1,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 1,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "error",
"error": "rpc error: code = Unknown desc = no static address parameters found",
"status": {
"code": 2,
"message": "no static address parameters found"
}
}
},
{
"time_ms": 1,
"kind": "stdout",
"data": {
"lines": [
"\n",
"WARNING: Be aware that loosing your l402.token file in .loop under your home directory will take your ability to spend funds sent to the static address via loop-ins or withdrawals. You will have to wait until the deposit expires and your loop client sweeps the funds back to your lnd wallet. The deposit expiry could be months in the future.\n",
"WARNING: Be aware that losing your l402.token file in .loop under your home directory will take your ability to spend funds sent to the static address via loop-ins or withdrawals. You will have to wait until the deposit expires and your loop client sweeps the funds back to your lnd wallet. The deposit expiry could be months in the future.\n",
"\n",
"CONTINUE WITH NEW ADDRESS? (y/n): "
]

2
go.mod
View file

@ -177,7 +177,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect

View file

@ -17,6 +17,7 @@ import (
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/assets"
"github.com/lightninglabs/loop/backup"
"github.com/lightninglabs/loop/instantout"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightninglabs/loop/loopdb"
@ -631,6 +632,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
withdrawalManager *withdraw.Manager
openChannelManager *openchannel.Manager
staticLoopInManager *loopin.Manager
backupService *backup.Service
)
// Static address manager setup.
@ -745,6 +747,25 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
return fmt.Errorf("unable to create loop-in manager: %w", err)
}
backupService = backup.NewService(
d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, staticAddressManager,
)
_, err = staticAddressManager.EnsureStaticAddressSeed(d.mainCtx)
if err != nil {
warnf("Unable to initialize static address seed during "+
"startup: %v", err)
}
backupFile, err := backupService.WriteBackup(d.mainCtx)
if err != nil {
warnf("Unable to write startup loop backup: %v", err)
}
if backupFile != "" {
infof("Wrote encrypted loop backup to %s after initializing "+
"the current L402 generation", backupFile)
}
var (
reservationManager *reservation.Manager
instantOutManager *instantout.Manager
@ -813,6 +834,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
staticLoopInManager: staticLoopInManager,
openChannelManager: openChannelManager,
assetClient: d.assetClient,
backupService: backupService,
stopDaemon: d.Stop,
}

View file

@ -22,6 +22,7 @@ import (
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/assets"
"github.com/lightninglabs/loop/backup"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout"
"github.com/lightninglabs/loop/instantout/reservation"
@ -104,6 +105,7 @@ type swapClientServer struct {
staticLoopInManager *loopin.Manager
openChannelManager *openchannel.Manager
assetClient *assets.TapdClient
backupService *backup.Service
swaps map[lntypes.Hash]loop.SwapInfo
subscribers map[int]chan<- any
statusChan chan loop.SwapInfo
@ -1864,6 +1866,17 @@ func (s *swapClientServer) NewStaticAddress(ctx context.Context,
return nil, err
}
if s.backupService != nil {
backupFile, backupErr := s.backupService.WriteBackup(ctx)
if backupErr != nil {
warnf("Unable to write loop backup after static address "+
"request: %v", backupErr)
} else if backupFile != "" {
infof("Wrote encrypted loop backup to %s after static "+
"address request", backupFile)
}
}
sendCoinsResp, err := s.sendCoinsToStaticAddress(
ctx, staticAddress.String(), sendCoinsReq,
)

View file

@ -94,6 +94,11 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) {
return m, nil
}
// CurrentHeight returns the manager's latest observed block height.
func (m *Manager) CurrentHeight() int32 {
return m.currentHeight.Load()
}
// Run runs the address manager.
func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
newBlockChan, newBlockErrChan, err :=

View file

@ -7,7 +7,7 @@ import (
)
// TestStaticAddressKeyFamiliesAreDisjoint documents the key-family split used
// by static-address HTLC, receive and change key derivation.
// by static-address backups and HTLC, receive and change key derivation.
func TestStaticAddressKeyFamiliesAreDisjoint(t *testing.T) {
families := map[int32]string{
KeyFamily: "swap htlc",