mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
backup: add encrypted L402 static address backups
This commit is contained in:
parent
c9c80b212d
commit
800d1837ee
9 changed files with 1526 additions and 9 deletions
692
backup/service.go
Normal file
692
backup/service.go
Normal 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 ¤tTokenState{
|
||||
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
762
backup/service_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 " +
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 :=
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue