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