PassAGE/backup.go
fraggle 3552db50c2 Initial commit: PassAGE password manager
- AGE encryption with master password model
- Core commands: init, show, insert, edit, generate, rm, mv, cp, find, grep, ls
- Git integration for version control
- Clipboard support (X11 and Wayland)
- Secure password generation
- Backup and restore functionality
- Comprehensive security features
- Complete documentation
2026-01-11 18:48:01 -04:00

307 lines
8.1 KiB
Go

package main
import (
"archive/tar"
"bufio"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// cmdBackup creates a backup of the password store
func cmdBackup(args []string) {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
outputFlag := fs.String("output", "", "output backup file path (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)")
outputShort := fs.String("o", "", "output backup file path (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
if !checkStoreInitialized(storeDir) {
fmt.Fprintf(os.Stderr, "Error: password store not initialized. Run 'passage init' first\n")
os.Exit(1)
}
// Determine output file
outputFile := *outputFlag
if outputFile == "" {
outputFile = *outputShort
}
if outputFile == "" {
// Generate default filename with timestamp
timestamp := time.Now().Format("20060102-150405")
outputFile = fmt.Sprintf("passage-backup-%s.tar.gz", timestamp)
}
// Validate output path
if err := checkSneakyPaths(outputFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid output path: %v\n", err)
os.Exit(1)
}
// Create backup file
backupFile, err := os.Create(outputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create backup file: %v\n", err)
os.Exit(1)
}
defer backupFile.Close()
// Create gzip writer
gzipWriter := gzip.NewWriter(backupFile)
defer gzipWriter.Close()
// Create tar writer
tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close()
// Calculate checksum while writing
hash := sha256.New()
multiWriter := io.MultiWriter(tarWriter, hash)
// Walk the store directory and add files to tar
fileCount := 0
err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip .git - it's usually huge and users can backup it separately if they want
if info.IsDir() && strings.Contains(path, ".git") {
return filepath.SkipDir
}
// Skip directories themselves
if info.IsDir() {
return nil
}
// Calculate relative path
relPath, err := filepath.Rel(storeDir, path)
if err != nil {
return err
}
// Create tar header
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
header.Mode = int64(info.Mode())
// Write header
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
// Open file and copy contents
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// Copy file contents through multi-writer (to tar and hash)
if _, err := io.Copy(multiWriter, file); err != nil {
return err
}
fileCount++
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err)
os.Exit(1)
}
// Finalize tar and gzip
tarWriter.Close()
gzipWriter.Close()
backupFile.Close()
// Calculate final checksum
checksum := hex.EncodeToString(hash.Sum(nil))
// Write checksum file for verification later
checksumFile := outputFile + ".sha256"
if err := os.WriteFile(checksumFile, []byte(checksum+" "+filepath.Base(outputFile)+"\n"), 0644); err != nil {
// Checksum file failed? Not critical, but warn about it
fmt.Fprintf(os.Stderr, "Warning: failed to write checksum file: %v\n", err)
}
fmt.Printf("Backup created: %s\n", outputFile)
fmt.Printf("Files backed up: %d\n", fileCount)
fmt.Printf("Checksum: %s\n", checksum)
fmt.Printf("Checksum file: %s\n", checksumFile)
}
// cmdRestore restores a backup of the password store
func cmdRestore(args []string) {
fs := flag.NewFlagSet("restore", flag.ExitOnError)
force := fs.Bool("force", false, "overwrite existing store without prompting")
forceShort := fs.Bool("f", false, "overwrite existing store without prompting (short)")
skipVerify := fs.Bool("skip-verify", false, "skip checksum verification")
skipVerifyShort := fs.Bool("s", false, "skip checksum verification (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: passage restore [--force] [--skip-verify] backup-file.tar.gz\n")
os.Exit(1)
}
backupFile := fs.Arg(0)
// Validate backup file path
if err := checkSneakyPaths(backupFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid backup file path: %v\n", err)
os.Exit(1)
}
// Check if backup file exists
if _, err := os.Stat(backupFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: backup file not found: %s\n", backupFile)
os.Exit(1)
}
// Verify checksum if not skipped (you really shouldn't skip this)
if !*skipVerify && !*skipVerifyShort {
checksumFile := backupFile + ".sha256"
if _, err := os.Stat(checksumFile); err == nil {
expectedChecksumData, err := os.ReadFile(checksumFile)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(expectedChecksumData)), " ")
if len(lines) >= 1 {
expectedChecksum := strings.TrimSpace(lines[0])
if !verifyBackupChecksum(backupFile, expectedChecksum) {
fmt.Fprintf(os.Stderr, "Error: backup file checksum verification failed!\n")
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore anyway (not recommended)\n")
os.Exit(1)
}
fmt.Printf("Checksum verified: OK\n")
}
}
} else {
// No checksum file? That's suspicious - require explicit skip
fmt.Fprintf(os.Stderr, "Warning: checksum file not found: %s\n", checksumFile)
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore without verification\n")
os.Exit(1)
}
}
storeDir := getStoreDir()
// Check if store already exists
if checkStoreInitialized(storeDir) {
if !*force && !*forceShort {
fmt.Printf("Warning: password store already exists at %s\n", storeDir)
fmt.Printf("Restoring will overwrite existing files. Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
}
// Open backup file
file, err := os.Open(backupFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open backup file: %v\n", err)
os.Exit(1)
}
defer file.Close()
// Create gzip reader
gzipReader, err := gzip.NewReader(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read gzip archive: %v\n", err)
os.Exit(1)
}
defer gzipReader.Close()
// Create tar reader
tarReader := tar.NewReader(gzipReader)
// Extract files one by one
fileCount := 0
for {
header, err := tarReader.Next()
if err == io.EOF {
break // All done
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read tar archive: %v\n", err)
os.Exit(1)
}
// Paranoid path check - even in backups, we don't trust paths
if err := checkSneakyPaths(header.Name); err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid path in backup: %v\n", err)
os.Exit(1)
}
// Construct full path
targetPath := filepath.Join(storeDir, header.Name)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
// Create file
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create file: %v\n", err)
os.Exit(1)
}
// Copy file contents
if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
fmt.Fprintf(os.Stderr, "Error: failed to write file: %v\n", err)
os.Exit(1)
}
outFile.Close()
fileCount++
}
fmt.Printf("Restore completed: %d files restored\n", fileCount)
fmt.Printf("Store location: %s\n", storeDir)
}
// verifyBackupChecksum verifies the SHA256 checksum of a backup file
func verifyBackupChecksum(backupFile, expectedChecksum string) bool {
file, err := os.Open(backupFile)
if err != nil {
return false
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return false
}
actualChecksum := hex.EncodeToString(hash.Sum(nil))
return constantTimeStringCompare(actualChecksum, expectedChecksum)
}