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>
This commit is contained in:
Roland 2026-08-12 22:14:49 +07:00 committed by GitHub
parent 1c7abc62e9
commit edd283cdb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 234 additions and 28 deletions

View file

@ -1,9 +1,11 @@
package api package api
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"strings" "strings"
"time" "time"
@ -16,12 +18,48 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db" "github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger" "github.com/getAlby/hub/logger"
"github.com/getAlby/hub/utils" "github.com/getAlby/hub/utils"
"golang.org/x/crypto/pbkdf2" "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 { func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
logger.Logger.Info("Creating backup to migrate Alby Hub to another device") logger.Logger.Info("Creating backup to migrate Alby Hub to another device")
var err error var err error
@ -257,8 +295,22 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
return fmt.Errorf("failed to create zip reader: %w", err) 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") 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 { extractZipEntry := func(zipFile *zip.File) error {
// Entry names come from the archive and must not be trusted. Reject any // Entry names come from the archive and must not be trusted. Reject any
// name that is absolute or points outside the restore directory via // name that is absolute or points outside the restore directory via
@ -268,11 +320,11 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name) return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
} }
fsFilePath := filepath.Join(restoreDir, entryName) fsFilePath := filepath.Join(stagingDir, entryName)
// Confirm the cleaned path is still contained within the restore // Confirm the cleaned path is still contained within the staging
// directory. // directory.
if fsFilePath != restoreDir && !strings.HasPrefix(fsFilePath, restoreDir+string(os.PathSeparator)) { if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name) return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
} }
@ -308,6 +360,13 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
} }
logger.Logger.WithField("count", len(zr.File)).Info("Extracted files") 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() { go func() {
logger.Logger.Info("Backup restored. Shutting down Alby Hub...") logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
api.svc.Shutdown() api.svc.Shutdown()
@ -328,12 +387,17 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
} }
func encryptingWriter(w io.Writer, password string) (io.Writer, error) { func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
salt := make([]byte, 8) scheme := backupCiphers[0]
salt := make([]byte, scheme.saltSize)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err) return nil, fmt.Errorf("failed to generate salt: %w", err)
} }
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New) 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) block, err := aes.NewCipher(encKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err) return nil, fmt.Errorf("failed to create AES cipher: %w", err)
@ -354,9 +418,8 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
return nil, fmt.Errorf("failed to write IV: %w", err) return nil, fmt.Errorf("failed to write IV: %w", err)
} }
stream := cipher.NewOFB(block, iv)
cw := &cipher.StreamWriter{ cw := &cipher.StreamWriter{
S: stream, S: scheme.newStream(block, iv),
W: w, W: w,
} }
@ -364,27 +427,61 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
} }
func decryptingReader(r io.Reader, password string) (io.Reader, error) { func decryptingReader(r io.Reader, password string) (io.Reader, error) {
salt := make([]byte, 8) // Read the largest possible header (salt, IV and the first bytes of the
if _, err := io.ReadFull(r, salt); err != nil { // archive) upfront, then trial-decrypt with each supported cipher scheme
return nil, fmt.Errorf("failed to read salt: %w", err) // 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)
} }
iv := make([]byte, aes.BlockSize) // Read the full header with io.ReadFull rather than io.ReadAtLeast: the
if _, err := io.ReadFull(r, iv); err != nil { // reader may deliver short reads (e.g. a network request body), and
return nil, fmt.Errorf("failed to read IV: %w", err) // 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
} }
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New) return nil, errors.New("invalid unlock password or backup file")
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := cipher.NewOFB(block, iv)
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return cr, nil
} }

View file

@ -3,11 +3,14 @@ package api
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"encoding/hex"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"testing" "testing"
"testing/iotest"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -148,15 +151,111 @@ func TestRestoreBackupRejectsPathTraversal(t *testing.T) {
cw, err := encryptingWriter(&buf, unlockPassword) cw, err := encryptingWriter(&buf, unlockPassword)
require.NoError(t, err) require.NoError(t, err)
zw := zip.NewWriter(cw) zw := zip.NewWriter(cw)
entryWriter, err := zw.Create(escapeEntryName) // A valid entry before the malicious one, to verify that a partially
// extracted archive is not left behind when a later entry fails.
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
entryWriter, err = zw.Create(escapeEntryName)
require.NoError(t, err) require.NoError(t, err)
_, err = entryWriter.Write([]byte("pwned")) _, err = entryWriter.Write([]byte("pwned"))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, zw.Close()) require.NoError(t, zw.Close())
err = theAPI.RestoreBackup(unlockPassword, &buf) err = theAPI.RestoreBackup(unlockPassword, &buf)
require.Error(t, err) require.ErrorContains(t, err, "refusing to extract zip entry outside restore directory")
_, statErr := os.Stat(escapeTarget) _, statErr := os.Stat(escapeTarget)
require.True(t, os.IsNotExist(statErr), "traversal entry must not be written outside the restore directory") require.True(t, os.IsNotExist(statErr), "traversal entry must not be written outside the restore directory")
// The failed restore must not leave a restore directory (which would be
// applied on the next startup) or any staging leftovers.
_, statErr = os.Stat(filepath.Join(workDir, "restore"))
require.True(t, os.IsNotExist(statErr), "failed restore must not leave a restore directory")
entries, err := os.ReadDir(workDir)
require.NoError(t, err)
for _, entry := range entries {
require.False(t, strings.HasPrefix(entry.Name(), "albyhub-restore-"), "failed restore must not leave a staging directory")
}
}
// legacyBackupFixture is a backup file created with the encryption scheme
// used by older versions (PBKDF2 key derivation), encrypted with the
// password "test-unlock-password". Its archive contains a single "nwc.db"
// entry with the contents "legacy backup contents".
const legacyBackupFixture = "0102030405060708101112131415161718191a1b1c1d1e1f8eca79631915f679a00cdd95d3f20d8d169eb9aa5d52642ca13b93886c3c7d7ba4b759462bc9dd8deccf638edcc9b5b9fda3d23dcd904cf6e99bc57ac59c4df6be5aa676542b7cbc9998029420c0ae5a6986c735150ababde5b382560acaebd5894aa4420924f1ced63fde570adc60c43b32e9e14a0ef60c379da5cac1be0000845992ea072ead036e336c7b859e8d018c4ef61667e3f520fe01"
// TestDecryptingReaderLegacyBackup verifies that backup files created by
// older versions can still be decrypted.
func TestDecryptingReaderLegacyBackup(t *testing.T) {
encrypted, err := hex.DecodeString(legacyBackupFixture)
require.NoError(t, err)
cr, err := decryptingReader(bytes.NewReader(encrypted), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "legacy backup contents", string(dbContents))
}
// TestDecryptingReaderFragmentedReader verifies that a backup file is
// decrypted correctly even when the reader delivers one byte at a time,
// which would truncate the header if it were not read in full.
func TestDecryptingReaderFragmentedReader(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
cr, err := decryptingReader(iotest.OneByteReader(bytes.NewReader(buf.Bytes())), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "backup contents", string(dbContents))
}
// TestDecryptingReaderWrongPassword verifies that decryption fails upfront
// when the password does not match the backup file.
func TestDecryptingReaderWrongPassword(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
_, err = decryptingReader(bytes.NewReader(buf.Bytes()), "wrong-password")
require.Error(t, err)
} }

View file

@ -109,6 +109,16 @@ export function MigrateNode() {
another device and use the Advanced option during the onboarding. another device and use the Advanced option during the onboarding.
</p> </p>
</div> </div>
<div className="flex flex-col gap-1">
<div className="flex gap-3 items-center">
<TriangleAlertIcon className="size-4" />
<h3>Never share your migration file</h3>
</div>
<p className="text-sm ml-7">
Anyone with this file and your unlock password can access your
funds. Never send it to anyone. Alby support will never ask for it.
</p>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<InfoIcon className="size-4" /> <InfoIcon className="size-4" />