- 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
307 lines
8.1 KiB
Go
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)
|
|
}
|