backup.go raw

   1  package api
   2  
   3  import (
   4  	"bytes"
   5  	"errors"
   6  	"fmt"
   7  	"io"
   8  	"math"
   9  	"strings"
  10  	"time"
  11  
  12  	"archive/zip"
  13  	"os"
  14  	"path/filepath"
  15  
  16  	"crypto/aes"
  17  	"crypto/cipher"
  18  	"crypto/rand"
  19  	"crypto/sha256"
  20  
  21  	"github.com/getAlby/hub/config"
  22  	"github.com/getAlby/hub/db"
  23  	"github.com/getAlby/hub/logger"
  24  	"github.com/getAlby/hub/utils"
  25  	"golang.org/x/crypto/pbkdf2"
  26  )
  27  
  28  // zipMagic is the ZIP local file header signature "PK\x03\x04" — the first
  29  // four bytes of every ZIP file, and therefore of every archive produced by
  30  // CreateBackup. decryptingReader uses it to detect which cipher scheme the
  31  // backup file was created with.
  32  var zipMagic = []byte{'P', 'K', 0x03, 0x04}
  33  
  34  // backupCipher describes one of the cipher schemes used for backup files,
  35  // which are laid out as salt || iv || encrypted zip archive.
  36  type backupCipher struct {
  37  	saltSize  int
  38  	deriveKey func(password string, salt []byte) ([]byte, error)
  39  	newStream func(block cipher.Block, iv []byte) cipher.Stream
  40  }
  41  
  42  var backupCiphers = []backupCipher{
  43  	// current scheme, used for all new backup files
  44  	{
  45  		saltSize: 32,
  46  		deriveKey: func(password string, salt []byte) ([]byte, error) {
  47  			key, _, err := config.DeriveKey(password, salt)
  48  			return key, err
  49  		},
  50  		newStream: cipher.NewCTR,
  51  	},
  52  	// legacy scheme, kept to restore backup files created by older versions
  53  	{
  54  		saltSize: 8,
  55  		deriveKey: func(password string, salt []byte) ([]byte, error) {
  56  			return pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New), nil
  57  		},
  58  		//nolint:staticcheck // OFB is required to read files created by older versions
  59  		newStream: cipher.NewOFB,
  60  	},
  61  }
  62  
  63  func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
  64  	logger.Logger.Info("Creating backup to migrate Alby Hub to another device")
  65  	var err error
  66  
  67  	if !api.cfg.CheckUnlockPassword(unlockPassword) {
  68  		return errors.New("invalid unlock password")
  69  	}
  70  
  71  	autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "")
  72  	if err != nil {
  73  		return err
  74  	}
  75  	if autoUnlockPassword != "" {
  76  		return errors.New("Please disable auto-unlock before using this feature")
  77  	}
  78  
  79  	dbBackend := api.db.Dialector.Name()
  80  	if dbBackend != "sqlite" && dbBackend != "postgres" {
  81  		return fmt.Errorf("migration with %s backend is currently not supported", dbBackend)
  82  	}
  83  
  84  	workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
  85  	if err != nil {
  86  		return fmt.Errorf("failed to get absolute workdir: %w", err)
  87  	}
  88  
  89  	lnStorageDir := ""
  90  
  91  	lnClient := api.svc.GetLNClient()
  92  	if lnClient == nil {
  93  		return fmt.Errorf("node not running")
  94  	}
  95  	lnStorageDir, err = lnClient.GetStorageDir()
  96  	if err != nil {
  97  		return fmt.Errorf("failed to get storage dir: %w", err)
  98  	}
  99  	logger.Logger.WithField("path", lnStorageDir).Info("Found node storage dir")
 100  
 101  	// Reset the routing data to decrease the LDK DB size
 102  	err = lnClient.ResetRouter("ALL")
 103  	if err != nil {
 104  		logger.Logger.WithError(err).Error("Failed to reset router")
 105  		return fmt.Errorf("failed to reset router: %w", err)
 106  	}
 107  	// Stop the app to ensure no new requests are processed.
 108  	api.svc.StopApp()
 109  
 110  	// Remove the OAuth access token from the DB to ensure the user
 111  	// has to re-auth with the correct OAuth client when they restore the backup
 112  	err = api.albyOAuthSvc.RemoveOAuthAccessToken()
 113  	if err != nil {
 114  		logger.Logger.WithError(err).Error("Failed to remove oauth access token")
 115  		return errors.New("failed to remove oauth access token")
 116  	}
 117  
 118  	// Locate the main database file.
 119  	dbFilePath := api.cfg.GetEnv().DatabaseUri
 120  
 121  	if dbBackend == "postgres" {
 122  		// The migration file must contain a sqlite database, so copy the
 123  		// contents of the postgres database into a temporary sqlite database
 124  		// and add that to the archive instead.
 125  		dbFilePath = filepath.Join(workDir, "migration.db")
 126  
 127  		removeConvertedDb := func() {
 128  			for _, path := range []string{dbFilePath, dbFilePath + "-wal", dbFilePath + "-shm"} {
 129  				if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
 130  					logger.Logger.WithError(err).WithField("path", path).Error("Failed to remove converted database file")
 131  				}
 132  			}
 133  		}
 134  		// Remove stale files from a previously failed migration attempt.
 135  		removeConvertedDb()
 136  		defer removeConvertedDb()
 137  
 138  		logger.Logger.WithField("path", dbFilePath).Info("Copying postgres database to sqlite")
 139  		sqliteDb, err := db.NewDB(dbFilePath, api.cfg.GetEnv().LogDBQueries)
 140  		if err != nil {
 141  			logger.Logger.WithError(err).Error("Failed to create sqlite database for migration")
 142  			return fmt.Errorf("failed to create sqlite database for migration: %w", err)
 143  		}
 144  
 145  		err = db.MigrateDB(api.db, sqliteDb)
 146  		if err != nil {
 147  			logger.Logger.WithError(err).Error("Failed to copy database contents to sqlite")
 148  			if stopErr := db.Stop(sqliteDb); stopErr != nil {
 149  				logger.Logger.WithError(stopErr).Error("Failed to stop sqlite database")
 150  			}
 151  			return fmt.Errorf("failed to copy database contents to sqlite: %w", err)
 152  		}
 153  
 154  		// Close the sqlite database to checkpoint the WAL before archiving it.
 155  		err = db.Stop(sqliteDb)
 156  		if err != nil {
 157  			logger.Logger.WithError(err).Error("Failed to stop sqlite database")
 158  			return fmt.Errorf("failed to close sqlite database: %w", err)
 159  		}
 160  	}
 161  
 162  	// Closing the database leaves the service in an inconsistent state,
 163  	// but that should not be a problem since the app is not expected
 164  	// to be used after its data is exported.
 165  	err = db.Stop(api.db)
 166  	if err != nil {
 167  		logger.Logger.WithError(err).Error("Failed to stop database")
 168  		return fmt.Errorf("failed to close database: %w", err)
 169  	}
 170  
 171  	var filesToArchive []string
 172  
 173  	if lnStorageDir != "" {
 174  		lnFiles, err := filepath.Glob(filepath.Join(workDir, lnStorageDir, "*"))
 175  		if err != nil {
 176  			return fmt.Errorf("failed to list files in the LNClient storage directory: %w", err)
 177  		}
 178  		logger.Logger.WithField("lnFiles", lnFiles).Info("Listed node storage dir")
 179  
 180  		// Avoid backing up log files.
 181  		lnFiles = utils.Filter(lnFiles, func(s string) bool {
 182  			return filepath.Ext(s) != ".log"
 183  		})
 184  
 185  		filesToArchive = append(filesToArchive, lnFiles...)
 186  	}
 187  
 188  	cw, err := encryptingWriter(w, unlockPassword)
 189  	if err != nil {
 190  		return fmt.Errorf("failed to create encrypted writer: %w", err)
 191  	}
 192  
 193  	zw := zip.NewWriter(cw)
 194  	defer zw.Close()
 195  
 196  	addFileToZip := func(fsPath, zipPath string) error {
 197  		inF, err := os.Open(fsPath)
 198  		if err != nil {
 199  			return fmt.Errorf("failed to open source file for reading: %w", err)
 200  		}
 201  		defer inF.Close()
 202  
 203  		outW, err := zw.Create(zipPath)
 204  		if err != nil {
 205  			return fmt.Errorf("failed to create zip entry: %w", err)
 206  		}
 207  
 208  		_, err = io.Copy(outW, inF)
 209  		return err
 210  	}
 211  
 212  	// Add the database file to the archive.
 213  	logger.Logger.WithField("nwc.db", dbFilePath).Info("adding nwc db to zip")
 214  	err = addFileToZip(dbFilePath, "nwc.db")
 215  	if err != nil {
 216  		logger.Logger.WithError(err).Error("Failed to zip nwc db")
 217  		return fmt.Errorf("failed to write nwc db file to zip: %w", err)
 218  	}
 219  
 220  	for _, fileToArchive := range filesToArchive {
 221  		logger.Logger.WithField("fileToArchive", fileToArchive).Info("adding file to zip")
 222  		relPath, err := filepath.Rel(workDir, fileToArchive)
 223  		if err != nil {
 224  			logger.Logger.WithError(err).Error("Failed to get relative path of input file")
 225  			return fmt.Errorf("failed to get relative path of input file: %w", err)
 226  		}
 227  
 228  		// Ensure forward slashes for zip format compatibility.
 229  		err = addFileToZip(fileToArchive, filepath.ToSlash(relPath))
 230  		if err != nil {
 231  			logger.Logger.WithError(err).Error("Failed to write file to zip")
 232  			return fmt.Errorf("failed to write input file to zip: %w", err)
 233  		}
 234  	}
 235  
 236  	// Finalize the archive before reporting success; the deferred close
 237  	// only covers early returns.
 238  	err = zw.Close()
 239  	if err != nil {
 240  		logger.Logger.WithError(err).Error("Failed to finalize migration archive")
 241  		return fmt.Errorf("failed to finalize migration archive: %w", err)
 242  	}
 243  
 244  	logger.Logger.Info("Successfully created backup to migrate Alby Hub to another device")
 245  
 246  	api.nodeMigrationFileCreated.Store(true)
 247  
 248  	return nil
 249  }
 250  
 251  func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
 252  	logger.Logger.Info("Restoring migration backup file")
 253  
 254  	workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
 255  	if err != nil {
 256  		return fmt.Errorf("failed to get absolute workdir: %w", err)
 257  	}
 258  
 259  	if strings.HasPrefix(api.cfg.GetEnv().DatabaseUri, "file:") {
 260  		return errors.New("cannot restore backup when database path is a file URI")
 261  	}
 262  
 263  	if api.db.Dialector.Name() != "sqlite" {
 264  		return errors.New("migration to non-sqlite backend is currently not supported")
 265  	}
 266  
 267  	cr, err := decryptingReader(r, unlockPassword)
 268  	if err != nil {
 269  		return fmt.Errorf("failed to create decrypted reader: %w", err)
 270  	}
 271  
 272  	tmpF, err := os.CreateTemp(api.cfg.GetEnv().Workdir, "albyhub-*.bkp")
 273  	if err != nil {
 274  		return fmt.Errorf("failed to create temporary output file: %w", err)
 275  	}
 276  	tmpName := tmpF.Name()
 277  	defer os.Remove(tmpName)
 278  	defer tmpF.Close()
 279  
 280  	zipSize, err := io.Copy(tmpF, cr)
 281  	if err != nil {
 282  		return fmt.Errorf("failed to decrypt backup data into temporary file: %w", err)
 283  	}
 284  
 285  	if err = tmpF.Sync(); err != nil {
 286  		return fmt.Errorf("failed to flush temporary file: %w", err)
 287  	}
 288  
 289  	if _, err = tmpF.Seek(0, 0); err != nil {
 290  		return fmt.Errorf("failed to seek to beginning of temporary file: %w", err)
 291  	}
 292  
 293  	zr, err := zip.NewReader(tmpF, zipSize)
 294  	if err != nil {
 295  		return fmt.Errorf("failed to create zip reader: %w", err)
 296  	}
 297  
 298  	if len(zr.File) == 0 {
 299  		return errors.New("backup file contains no files")
 300  	}
 301  
 302  	restoreDir := filepath.Join(workDir, "restore")
 303  
 304  	// Extract into a staging directory and only move it to the restore
 305  	// directory once every entry has been extracted, so that a failed
 306  	// extraction cannot leave a partial restore directory behind, which
 307  	// would be applied on the next startup.
 308  	stagingDir, err := os.MkdirTemp(workDir, "albyhub-restore-")
 309  	if err != nil {
 310  		return fmt.Errorf("failed to create staging directory: %w", err)
 311  	}
 312  	defer os.RemoveAll(stagingDir)
 313  
 314  	extractZipEntry := func(zipFile *zip.File) error {
 315  		// Entry names come from the archive and must not be trusted. Reject any
 316  		// name that is absolute or points outside the restore directory via
 317  		// ".." segments before joining it to a path.
 318  		entryName := filepath.FromSlash(zipFile.Name)
 319  		if !filepath.IsLocal(entryName) {
 320  			return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
 321  		}
 322  
 323  		fsFilePath := filepath.Join(stagingDir, entryName)
 324  
 325  		// Confirm the cleaned path is still contained within the staging
 326  		// directory.
 327  		if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) {
 328  			return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
 329  		}
 330  
 331  		if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
 332  			return fmt.Errorf("failed to create directory for zip entry: %w", err)
 333  		}
 334  
 335  		inF, err := zipFile.Open()
 336  		if err != nil {
 337  			return fmt.Errorf("failed to open zip entry for reading: %w", err)
 338  		}
 339  		defer inF.Close()
 340  
 341  		outF, err := os.OpenFile(fsFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 342  		if err != nil {
 343  			return fmt.Errorf("failed to create destination file: %w", err)
 344  		}
 345  		defer outF.Close()
 346  
 347  		if _, err = io.Copy(outF, inF); err != nil {
 348  			return fmt.Errorf("failed to write zip entry to destination file: %w", err)
 349  		}
 350  
 351  		return nil
 352  	}
 353  
 354  	logger.Logger.WithField("count", len(zr.File)).Info("Extracting files")
 355  	for _, f := range zr.File {
 356  		logger.Logger.WithField("file", f.Name).Info("Extracting file")
 357  		if err = extractZipEntry(f); err != nil {
 358  			return fmt.Errorf("failed to extract zip entry: %w", err)
 359  		}
 360  	}
 361  	logger.Logger.WithField("count", len(zr.File)).Info("Extracted files")
 362  
 363  	if err = os.RemoveAll(restoreDir); err != nil {
 364  		return fmt.Errorf("failed to remove existing restore directory: %w", err)
 365  	}
 366  	if err = os.Rename(stagingDir, restoreDir); err != nil {
 367  		return fmt.Errorf("failed to move extracted files to restore directory: %w", err)
 368  	}
 369  
 370  	go func() {
 371  		logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
 372  		api.svc.Shutdown()
 373  		// ensure no -shm or -wal files exist as they will stop the restore
 374  		for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} {
 375  			err = os.Remove(filepath.Join(workDir, filename))
 376  			if err != nil && !errors.Is(err, os.ErrNotExist) {
 377  				logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore")
 378  			}
 379  		}
 380  
 381  		// schedule node shutdown after a few seconds to ensure frontend updates
 382  		time.Sleep(5 * time.Second)
 383  		os.Exit(0)
 384  	}()
 385  
 386  	return nil
 387  }
 388  
 389  func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
 390  	scheme := backupCiphers[0]
 391  
 392  	salt := make([]byte, scheme.saltSize)
 393  	if _, err := rand.Read(salt); err != nil {
 394  		return nil, fmt.Errorf("failed to generate salt: %w", err)
 395  	}
 396  
 397  	encKey, err := scheme.deriveKey(password, salt)
 398  	if err != nil {
 399  		return nil, fmt.Errorf("failed to derive encryption key: %w", err)
 400  	}
 401  	block, err := aes.NewCipher(encKey)
 402  	if err != nil {
 403  		return nil, fmt.Errorf("failed to create AES cipher: %w", err)
 404  	}
 405  
 406  	iv := make([]byte, aes.BlockSize)
 407  	if _, err = rand.Read(iv); err != nil {
 408  		return nil, fmt.Errorf("failed to generate IV: %w", err)
 409  	}
 410  
 411  	_, err = w.Write(salt)
 412  	if err != nil {
 413  		return nil, fmt.Errorf("failed to write salt: %w", err)
 414  	}
 415  
 416  	_, err = w.Write(iv)
 417  	if err != nil {
 418  		return nil, fmt.Errorf("failed to write IV: %w", err)
 419  	}
 420  
 421  	cw := &cipher.StreamWriter{
 422  		S: scheme.newStream(block, iv),
 423  		W: w,
 424  	}
 425  
 426  	return cw, nil
 427  }
 428  
 429  func decryptingReader(r io.Reader, password string) (io.Reader, error) {
 430  	// Read the largest possible header (salt, IV and the first bytes of the
 431  	// archive) upfront, then trial-decrypt with each supported cipher scheme
 432  	// and pick the one that produces the ZIP signature.
 433  	maxHeaderSize := 0
 434  	minHeaderSize := math.MaxInt
 435  	for _, scheme := range backupCiphers {
 436  		headerSize := scheme.saltSize + aes.BlockSize + len(zipMagic)
 437  		maxHeaderSize = max(maxHeaderSize, headerSize)
 438  		minHeaderSize = min(minHeaderSize, headerSize)
 439  	}
 440  
 441  	// Read the full header with io.ReadFull rather than io.ReadAtLeast: the
 442  	// reader may deliver short reads (e.g. a network request body), and
 443  	// stopping early could truncate the header of a scheme with a larger
 444  	// salt. A short file is only acceptable if it still covers the smallest
 445  	// scheme header.
 446  	header := make([]byte, maxHeaderSize)
 447  	n, err := io.ReadFull(r, header)
 448  	if err != nil && !(errors.Is(err, io.ErrUnexpectedEOF) && n >= minHeaderSize) {
 449  		return nil, fmt.Errorf("failed to read backup header: %w", err)
 450  	}
 451  	header = header[:n]
 452  
 453  	for _, scheme := range backupCiphers {
 454  		if len(header) < scheme.saltSize+aes.BlockSize+len(zipMagic) {
 455  			continue
 456  		}
 457  		salt := header[:scheme.saltSize]
 458  		iv := header[scheme.saltSize : scheme.saltSize+aes.BlockSize]
 459  		encrypted := header[scheme.saltSize+aes.BlockSize:]
 460  
 461  		encKey, err := scheme.deriveKey(password, salt)
 462  		if err != nil {
 463  			return nil, fmt.Errorf("failed to derive encryption key: %w", err)
 464  		}
 465  
 466  		block, err := aes.NewCipher(encKey)
 467  		if err != nil {
 468  			return nil, fmt.Errorf("failed to create AES cipher: %w", err)
 469  		}
 470  
 471  		stream := scheme.newStream(block, iv)
 472  		decrypted := make([]byte, len(encrypted))
 473  		stream.XORKeyStream(decrypted, encrypted)
 474  		if !bytes.Equal(decrypted[:len(zipMagic)], zipMagic) {
 475  			continue
 476  		}
 477  
 478  		cr := &cipher.StreamReader{
 479  			S: stream,
 480  			R: r,
 481  		}
 482  
 483  		return io.MultiReader(bytes.NewReader(decrypted), cr), nil
 484  	}
 485  
 486  	return nil, errors.New("invalid unlock password or backup file")
 487  }
 488