alby-hub/api/backup.go
Roland edd283cdb2
fix: update encryption scheme for node migration files (#2539)
* fix: update encryption scheme for node migration files

Migration files are now encrypted with AES-CTR using a key derived via
Argon2 with a 32-byte salt, the same derivation used for encrypted
configuration values. Files created by earlier versions can still be
restored: the restore path detects the scheme by trial-decrypting the
archive header and checking for the ZIP file signature, which also
rejects an incorrect unlock password up front instead of extracting
garbage.

The migration screen now also tells users to never share their
migration file with anyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reword migration file warning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: read full migration file header before detecting cipher scheme

io.ReadAtLeast can return once the smallest scheme's header is read,
which truncates the larger current-scheme header when the reader
delivers short reads (e.g. a network request body). Read the full
header and only tolerate a short read that still covers the smallest
scheme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: extract migration files to a staging directory during restore

If extraction failed partway through, the partially populated restore
directory was left in the working directory, and the next startup would
apply the incomplete restore. Extract to a staging directory and only
move it into place after every entry has been extracted successfully.
Also reject archives that contain no files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: assert traversal-specific error in restore backup test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 22:14:49 +07:00

487 lines
15 KiB
Go

package api
import (
"bytes"
"errors"
"fmt"
"io"
"math"
"strings"
"time"
"archive/zip"
"os"
"path/filepath"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/utils"
"golang.org/x/crypto/pbkdf2"
)
// zipMagic is the ZIP local file header signature "PK\x03\x04" — the first
// four bytes of every ZIP file, and therefore of every archive produced by
// CreateBackup. decryptingReader uses it to detect which cipher scheme the
// backup file was created with.
var zipMagic = []byte{'P', 'K', 0x03, 0x04}
// backupCipher describes one of the cipher schemes used for backup files,
// which are laid out as salt || iv || encrypted zip archive.
type backupCipher struct {
saltSize int
deriveKey func(password string, salt []byte) ([]byte, error)
newStream func(block cipher.Block, iv []byte) cipher.Stream
}
var backupCiphers = []backupCipher{
// current scheme, used for all new backup files
{
saltSize: 32,
deriveKey: func(password string, salt []byte) ([]byte, error) {
key, _, err := config.DeriveKey(password, salt)
return key, err
},
newStream: cipher.NewCTR,
},
// legacy scheme, kept to restore backup files created by older versions
{
saltSize: 8,
deriveKey: func(password string, salt []byte) ([]byte, error) {
return pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New), nil
},
//nolint:staticcheck // OFB is required to read files created by older versions
newStream: cipher.NewOFB,
},
}
func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
logger.Logger.Info("Creating backup to migrate Alby Hub to another device")
var err error
if !api.cfg.CheckUnlockPassword(unlockPassword) {
return errors.New("invalid unlock password")
}
autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "")
if err != nil {
return err
}
if autoUnlockPassword != "" {
return errors.New("Please disable auto-unlock before using this feature")
}
dbBackend := api.db.Dialector.Name()
if dbBackend != "sqlite" && dbBackend != "postgres" {
return fmt.Errorf("migration with %s backend is currently not supported", dbBackend)
}
workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
if err != nil {
return fmt.Errorf("failed to get absolute workdir: %w", err)
}
lnStorageDir := ""
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return fmt.Errorf("node not running")
}
lnStorageDir, err = lnClient.GetStorageDir()
if err != nil {
return fmt.Errorf("failed to get storage dir: %w", err)
}
logger.Logger.WithField("path", lnStorageDir).Info("Found node storage dir")
// Reset the routing data to decrease the LDK DB size
err = lnClient.ResetRouter("ALL")
if err != nil {
logger.Logger.WithError(err).Error("Failed to reset router")
return fmt.Errorf("failed to reset router: %w", err)
}
// Stop the app to ensure no new requests are processed.
api.svc.StopApp()
// Remove the OAuth access token from the DB to ensure the user
// has to re-auth with the correct OAuth client when they restore the backup
err = api.albyOAuthSvc.RemoveOAuthAccessToken()
if err != nil {
logger.Logger.WithError(err).Error("Failed to remove oauth access token")
return errors.New("failed to remove oauth access token")
}
// Locate the main database file.
dbFilePath := api.cfg.GetEnv().DatabaseUri
if dbBackend == "postgres" {
// The migration file must contain a sqlite database, so copy the
// contents of the postgres database into a temporary sqlite database
// and add that to the archive instead.
dbFilePath = filepath.Join(workDir, "migration.db")
removeConvertedDb := func() {
for _, path := range []string{dbFilePath, dbFilePath + "-wal", dbFilePath + "-shm"} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
logger.Logger.WithError(err).WithField("path", path).Error("Failed to remove converted database file")
}
}
}
// Remove stale files from a previously failed migration attempt.
removeConvertedDb()
defer removeConvertedDb()
logger.Logger.WithField("path", dbFilePath).Info("Copying postgres database to sqlite")
sqliteDb, err := db.NewDB(dbFilePath, api.cfg.GetEnv().LogDBQueries)
if err != nil {
logger.Logger.WithError(err).Error("Failed to create sqlite database for migration")
return fmt.Errorf("failed to create sqlite database for migration: %w", err)
}
err = db.MigrateDB(api.db, sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to copy database contents to sqlite")
if stopErr := db.Stop(sqliteDb); stopErr != nil {
logger.Logger.WithError(stopErr).Error("Failed to stop sqlite database")
}
return fmt.Errorf("failed to copy database contents to sqlite: %w", err)
}
// Close the sqlite database to checkpoint the WAL before archiving it.
err = db.Stop(sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to stop sqlite database")
return fmt.Errorf("failed to close sqlite database: %w", err)
}
}
// Closing the database leaves the service in an inconsistent state,
// but that should not be a problem since the app is not expected
// to be used after its data is exported.
err = db.Stop(api.db)
if err != nil {
logger.Logger.WithError(err).Error("Failed to stop database")
return fmt.Errorf("failed to close database: %w", err)
}
var filesToArchive []string
if lnStorageDir != "" {
lnFiles, err := filepath.Glob(filepath.Join(workDir, lnStorageDir, "*"))
if err != nil {
return fmt.Errorf("failed to list files in the LNClient storage directory: %w", err)
}
logger.Logger.WithField("lnFiles", lnFiles).Info("Listed node storage dir")
// Avoid backing up log files.
lnFiles = utils.Filter(lnFiles, func(s string) bool {
return filepath.Ext(s) != ".log"
})
filesToArchive = append(filesToArchive, lnFiles...)
}
cw, err := encryptingWriter(w, unlockPassword)
if err != nil {
return fmt.Errorf("failed to create encrypted writer: %w", err)
}
zw := zip.NewWriter(cw)
defer zw.Close()
addFileToZip := func(fsPath, zipPath string) error {
inF, err := os.Open(fsPath)
if err != nil {
return fmt.Errorf("failed to open source file for reading: %w", err)
}
defer inF.Close()
outW, err := zw.Create(zipPath)
if err != nil {
return fmt.Errorf("failed to create zip entry: %w", err)
}
_, err = io.Copy(outW, inF)
return err
}
// Add the database file to the archive.
logger.Logger.WithField("nwc.db", dbFilePath).Info("adding nwc db to zip")
err = addFileToZip(dbFilePath, "nwc.db")
if err != nil {
logger.Logger.WithError(err).Error("Failed to zip nwc db")
return fmt.Errorf("failed to write nwc db file to zip: %w", err)
}
for _, fileToArchive := range filesToArchive {
logger.Logger.WithField("fileToArchive", fileToArchive).Info("adding file to zip")
relPath, err := filepath.Rel(workDir, fileToArchive)
if err != nil {
logger.Logger.WithError(err).Error("Failed to get relative path of input file")
return fmt.Errorf("failed to get relative path of input file: %w", err)
}
// Ensure forward slashes for zip format compatibility.
err = addFileToZip(fileToArchive, filepath.ToSlash(relPath))
if err != nil {
logger.Logger.WithError(err).Error("Failed to write file to zip")
return fmt.Errorf("failed to write input file to zip: %w", err)
}
}
// Finalize the archive before reporting success; the deferred close
// only covers early returns.
err = zw.Close()
if err != nil {
logger.Logger.WithError(err).Error("Failed to finalize migration archive")
return fmt.Errorf("failed to finalize migration archive: %w", err)
}
logger.Logger.Info("Successfully created backup to migrate Alby Hub to another device")
api.nodeMigrationFileCreated.Store(true)
return nil
}
func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
logger.Logger.Info("Restoring migration backup file")
workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
if err != nil {
return fmt.Errorf("failed to get absolute workdir: %w", err)
}
if strings.HasPrefix(api.cfg.GetEnv().DatabaseUri, "file:") {
return errors.New("cannot restore backup when database path is a file URI")
}
if api.db.Dialector.Name() != "sqlite" {
return errors.New("migration to non-sqlite backend is currently not supported")
}
cr, err := decryptingReader(r, unlockPassword)
if err != nil {
return fmt.Errorf("failed to create decrypted reader: %w", err)
}
tmpF, err := os.CreateTemp(api.cfg.GetEnv().Workdir, "albyhub-*.bkp")
if err != nil {
return fmt.Errorf("failed to create temporary output file: %w", err)
}
tmpName := tmpF.Name()
defer os.Remove(tmpName)
defer tmpF.Close()
zipSize, err := io.Copy(tmpF, cr)
if err != nil {
return fmt.Errorf("failed to decrypt backup data into temporary file: %w", err)
}
if err = tmpF.Sync(); err != nil {
return fmt.Errorf("failed to flush temporary file: %w", err)
}
if _, err = tmpF.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning of temporary file: %w", err)
}
zr, err := zip.NewReader(tmpF, zipSize)
if err != nil {
return fmt.Errorf("failed to create zip reader: %w", err)
}
if len(zr.File) == 0 {
return errors.New("backup file contains no files")
}
restoreDir := filepath.Join(workDir, "restore")
// Extract into a staging directory and only move it to the restore
// directory once every entry has been extracted, so that a failed
// extraction cannot leave a partial restore directory behind, which
// would be applied on the next startup.
stagingDir, err := os.MkdirTemp(workDir, "albyhub-restore-")
if err != nil {
return fmt.Errorf("failed to create staging directory: %w", err)
}
defer os.RemoveAll(stagingDir)
extractZipEntry := func(zipFile *zip.File) error {
// Entry names come from the archive and must not be trusted. Reject any
// name that is absolute or points outside the restore directory via
// ".." segments before joining it to a path.
entryName := filepath.FromSlash(zipFile.Name)
if !filepath.IsLocal(entryName) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
fsFilePath := filepath.Join(stagingDir, entryName)
// Confirm the cleaned path is still contained within the staging
// directory.
if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory for zip entry: %w", err)
}
inF, err := zipFile.Open()
if err != nil {
return fmt.Errorf("failed to open zip entry for reading: %w", err)
}
defer inF.Close()
outF, err := os.OpenFile(fsFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer outF.Close()
if _, err = io.Copy(outF, inF); err != nil {
return fmt.Errorf("failed to write zip entry to destination file: %w", err)
}
return nil
}
logger.Logger.WithField("count", len(zr.File)).Info("Extracting files")
for _, f := range zr.File {
logger.Logger.WithField("file", f.Name).Info("Extracting file")
if err = extractZipEntry(f); err != nil {
return fmt.Errorf("failed to extract zip entry: %w", err)
}
}
logger.Logger.WithField("count", len(zr.File)).Info("Extracted files")
if err = os.RemoveAll(restoreDir); err != nil {
return fmt.Errorf("failed to remove existing restore directory: %w", err)
}
if err = os.Rename(stagingDir, restoreDir); err != nil {
return fmt.Errorf("failed to move extracted files to restore directory: %w", err)
}
go func() {
logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
api.svc.Shutdown()
// ensure no -shm or -wal files exist as they will stop the restore
for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} {
err = os.Remove(filepath.Join(workDir, filename))
if err != nil && !errors.Is(err, os.ErrNotExist) {
logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore")
}
}
// schedule node shutdown after a few seconds to ensure frontend updates
time.Sleep(5 * time.Second)
os.Exit(0)
}()
return nil
}
func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
scheme := backupCiphers[0]
salt := make([]byte, scheme.saltSize)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
iv := make([]byte, aes.BlockSize)
if _, err = rand.Read(iv); err != nil {
return nil, fmt.Errorf("failed to generate IV: %w", err)
}
_, err = w.Write(salt)
if err != nil {
return nil, fmt.Errorf("failed to write salt: %w", err)
}
_, err = w.Write(iv)
if err != nil {
return nil, fmt.Errorf("failed to write IV: %w", err)
}
cw := &cipher.StreamWriter{
S: scheme.newStream(block, iv),
W: w,
}
return cw, nil
}
func decryptingReader(r io.Reader, password string) (io.Reader, error) {
// Read the largest possible header (salt, IV and the first bytes of the
// archive) upfront, then trial-decrypt with each supported cipher scheme
// and pick the one that produces the ZIP signature.
maxHeaderSize := 0
minHeaderSize := math.MaxInt
for _, scheme := range backupCiphers {
headerSize := scheme.saltSize + aes.BlockSize + len(zipMagic)
maxHeaderSize = max(maxHeaderSize, headerSize)
minHeaderSize = min(minHeaderSize, headerSize)
}
// Read the full header with io.ReadFull rather than io.ReadAtLeast: the
// reader may deliver short reads (e.g. a network request body), and
// stopping early could truncate the header of a scheme with a larger
// salt. A short file is only acceptable if it still covers the smallest
// scheme header.
header := make([]byte, maxHeaderSize)
n, err := io.ReadFull(r, header)
if err != nil && !(errors.Is(err, io.ErrUnexpectedEOF) && n >= minHeaderSize) {
return nil, fmt.Errorf("failed to read backup header: %w", err)
}
header = header[:n]
for _, scheme := range backupCiphers {
if len(header) < scheme.saltSize+aes.BlockSize+len(zipMagic) {
continue
}
salt := header[:scheme.saltSize]
iv := header[scheme.saltSize : scheme.saltSize+aes.BlockSize]
encrypted := header[scheme.saltSize+aes.BlockSize:]
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := scheme.newStream(block, iv)
decrypted := make([]byte, len(encrypted))
stream.XORKeyStream(decrypted, encrypted)
if !bytes.Equal(decrypted[:len(zipMagic)], zipMagic) {
continue
}
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return io.MultiReader(bytes.NewReader(decrypted), cr), nil
}
return nil, errors.New("invalid unlock password or backup file")
}