Compare commits

..

No commits in common. "81f0030069e2cbaa6012919284acf60e255503a7" and "32db394b1eb6877ee8dc3a7e6760345a6816be97" have entirely different histories.

16 changed files with 2 additions and 3166 deletions

42
.gitignore vendored
View File

@ -1,42 +0,0 @@
# Binaries
passage
passage.exe
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.html
# Dependency directories
vendor/
# Go workspace file
go.work
go.work.sum
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Build artifacts
passage.1
dist/
build/
# Temporary files
*.tmp
*.bak
*.log
# User-specific files
*.local

View File

@ -1,73 +0,0 @@
# Contributing to PassAGE
Thank you for your interest in contributing to PassAGE!
## Getting Started
1. **Fork the repository** (if using a forge like Gitea)
2. **Clone the repository** locally
3. **Create a branch** for your changes
4. **Make your changes** and test them
5. **Submit a merge request** or push your changes
## Development Setup
1. **Fork and clone the repository:**
```bash
git clone <repository-url>
cd PassAGE
```
2. **Install dependencies:**
```bash
go mod download
```
3. **Build the project:**
```bash
make build
# or
go build -o passage .
```
4. **Run tests:**
```bash
make test
# or
go test ./...
```
## Code Style
- Follow standard Go formatting (`go fmt`)
- Run `go vet` before committing
- Use meaningful variable and function names
- Add comments for non-obvious code
## Testing
- Write tests for new features
- Ensure all tests pass: `go test ./...`
- Test with race detector: `make test-race`
## Submitting Changes
1. Create a branch for your changes
2. Make your changes
3. Ensure code compiles and tests pass
4. Submit a pull request with a clear description
## Building Manpages
If you modify documentation, rebuild the manpage:
```bash
make man
```
Requires `pandoc` or `go-md2man` to be installed.
## Questions?
Feel free to open an issue or start a discussion in the repository.

View File

@ -1,173 +0,0 @@
# Installation Guide
Complete installation instructions for PassAGE.
**Quick Install:** `go install <repository-url>@latest`
## Quick Install
### Using Go (Recommended)
```bash
go install <repository-url>@latest
```
### From Source
```bash
# Clone repository and build
git clone <repository-url>
cd PassAGE
make install
# Or for user installation (no sudo)
make install-user
```
## System Requirements
- **Go 1.21 or later** - Required for building from source
- **Linux/Unix system** - Designed for Unix-like operating systems
- **xclip or wl-clipboard** - For clipboard support (X11/Wayland)
## Build Dependencies
### Required
- Go toolchain (1.21+)
### Optional
- **pandoc** - For building manpages
- Debian/Ubuntu: `sudo apt-get install pandoc`
- Arch: `sudo pacman -S pandoc`
- Or use `go-md2man`: `go install github.com/cpuguy83/go-md2man/v2@latest`
## Installation Steps
### 1. Clone the Repository
```bash
git clone <repository-url>
cd PassAGE
```
### 2. Download Dependencies
```bash
go mod download
```
This downloads all required Go modules.
### 3. Build
```bash
# Simple build
go build -o passage .
# Or use Makefile
make build
# Release build (optimized, smaller)
make build-release
```
### 4. Install
**System-wide (requires sudo):**
```bash
sudo make install
```
**User installation (no sudo):**
```bash
make install-user
```
### 5. Install Manpage (Optional)
**System-wide:**
```bash
make man
sudo make install-man
```
**User installation:**
```bash
make man
make install-user-man
```
## Verify Installation
```bash
# Check version
passage version
# View help
passage help
# View manpage (if installed)
man passage
```
## Troubleshooting
### Build Fails
1. **Check Go version:**
```bash
go version # Should be 1.21 or later
```
2. **Verify dependencies:**
```bash
go mod verify
go mod tidy
```
3. **Clean and rebuild:**
```bash
make clean
make build
```
### Manpage Won't Build
Install `pandoc`:
- Debian/Ubuntu: `sudo apt-get install pandoc`
- macOS: `brew install pandoc`
Or use `go-md2man`:
```bash
go install github.com/cpuguy83/go-md2man/v2@latest
```
### Clipboard Doesn't Work
Ensure you have clipboard tools installed:
- X11: `xclip` or `xsel`
- Wayland: `wl-clipboard`
Install:
- Debian/Ubuntu: `sudo apt-get install xclip` or `sudo apt-get install wl-clipboard`
- Arch: `sudo pacman -S xclip` or `sudo pacman -S wl-clipboard`
- Other distributions: Install `xclip` or `wl-clipboard` from your package manager
## Uninstallation
```bash
# Remove binary
sudo rm /usr/local/bin/passage
# or for user installation
rm ~/.local/bin/passage
# Remove manpage
sudo rm /usr/local/share/man/man1/passage.1
# or for user installation
rm ~/.local/share/man/man1/passage.1
# Update man database
sudo mandb
# or
mandb ~/.local/share/man
```

View File

@ -1,63 +0,0 @@
.PHONY: build install clean test release man install-man
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
PREFIX ?= /usr/local
MANPREFIX ?= $(PREFIX)/share/man
build:
go build -o passage .
build-release:
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o passage .
install: build
@mkdir -p $(DESTDIR)$(PREFIX)/bin
cp passage $(DESTDIR)$(PREFIX)/bin/passage
@echo "Installed passage to $(DESTDIR)$(PREFIX)/bin/passage"
install-user: build
@mkdir -p ~/.local/bin
cp passage ~/.local/bin/passage
@echo "Installed passage to ~/.local/bin/passage"
man: passage.1
passage.1: passage.1.md
@if command -v pandoc >/dev/null 2>&1; then \
pandoc passage.1.md -s -t man -o passage.1; \
elif command -v go-md2man >/dev/null 2>&1; then \
go-md2man -in passage.1.md -out passage.1; \
else \
echo "Error: pandoc or go-md2man required to build manpage"; \
echo "Install pandoc: sudo apt-get install pandoc (Debian/Ubuntu)"; \
echo "Or install go-md2man: go install github.com/cpuguy83/go-md2man/v2@latest"; \
exit 1; \
fi
install-man: man
@mkdir -p $(DESTDIR)$(MANPREFIX)/man1
cp passage.1 $(DESTDIR)$(MANPREFIX)/man1/passage.1
@if command -v mandb >/dev/null 2>&1; then \
mandb $(DESTDIR)$(MANPREFIX) >/dev/null 2>&1 || true; \
fi
@echo "Installed manpage to $(DESTDIR)$(MANPREFIX)/man1/passage.1"
install-user-man: man
@mkdir -p ~/.local/share/man/man1
cp passage.1 ~/.local/share/man/man1/passage.1
@if command -v mandb >/dev/null 2>&1; then \
mandb ~/.local/share/man >/dev/null 2>&1 || true; \
fi
@echo "Installed manpage to ~/.local/share/man/man1/passage.1"
clean:
rm -f passage passage.1
test:
go test ./...
test-race:
go test -race ./...
release: build-release
@echo "Built release version: $(VERSION)"

220
README.md
View File

@ -1,220 +1,4 @@
# PassAGE
A modern password manager using AGE encryption.
**Quick Links:** [Installation](#installation) • [Quick Start](#quick-start) • [Commands](#usage) • [Security](#security) • [Contributing](CONTRIBUTING.md)
## Features
- **AGE encryption**: Uses AGE (Actually Good Encryption) for secure password storage
- **Master password model**: Single password protects all stored passwords
- **Git integration**: Optional git repository support for version control
- **Command-line interface**: Simple, intuitive commands
- **Linux/Unix**: Designed for Linux and Unix-like systems
- **Clipboard support**: X11 and Wayland clipboard integration
- **Secure password generation**: Cryptographically secure random password generation
## Installation
### Prerequisites
- **Go 1.21 or later** - Required for building
- **Linux/Unix system** - Designed for Unix-like operating systems
- **pandoc** or **go-md2man** (optional) - For building manpages
- **xclip** or **wl-clipboard** - For clipboard support (X11/Wayland)
### Build from Source
```bash
# Download dependencies
go mod download
# Build
go build -o passage .
# Or use Makefile
make build
```
**Installation:**
```bash
# System-wide installation
sudo make install
# User installation (no sudo required)
make install-user
```
**Or install directly with go:**
```bash
go install <repository-url>@latest
```
#### Build Options
For production builds, you may want to use additional flags:
```bash
# Build with version information
go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o passage
# Build with trimmed paths (for reproducible builds)
go build -trimpath -o passage
# Build with race detector (for testing)
go build -race -o passage
# Build optimized binary (smaller, faster)
go build -ldflags "-s -w" -trimpath -o passage
```
**Build flags explained:**
- `-ldflags "-X main.version=..."` - Inject version information at build time
- `-trimpath` - Remove file system paths for reproducible builds
- `-race` - Enable race detector (for debugging concurrency issues)
- `-ldflags "-s -w"` - Strip debug symbols and disable DWARF generation (smaller binary)
### Install Binary
Pre-built binaries may be available from the releases page.
## Quick Start
### 1. Initialize the password store
Initialize the password store with a master password:
```bash
passage init
```
This will prompt you to:
- Enter a master password (used to encrypt/decrypt all passwords)
- Confirm the master password
The master password is required for all operations.
### 2. Add a password
```bash
passage insert example.com
```
### 3. Retrieve a password
```bash
passage show example.com
```
### 4. Generate a password
```bash
passage generate example.com 32
```
## Usage
### Commands
- `passage init [--path=subfolder]` - Initialize password store with master password
- `passage [ls] [subfolder]` - List passwords
- `passage find pass-names...` - Find passwords by name
- `passage [show] [--clip[=line-number]] pass-name` - Show password
- `passage grep search-string` - Search within passwords
- `passage insert [--multiline] [--force] pass-name` - Insert password
- `passage edit pass-name` - Edit password
- `passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]` - Generate password
- `passage rm [--recursive] [--force] pass-name` - Remove password
- `passage mv [--force] old-path new-path` - Move/rename password
- `passage cp [--force] old-path new-path` - Copy password
- `passage git git-command-args...` - Run git commands
- `passage help` - Show help
- `passage version` - Show version
### Environment Variables
passage respects the following environment variables:
- **PASSAGE_DIR** - Path to password store (default: `~/.passage-store`)
```bash
export PASSAGE_DIR=~/my-passwords
```
- **PASSAGE_CLIP_TIME** - Time in seconds to keep password in clipboard before auto-clearing (default: 10)
```bash
export PASSAGE_CLIP_TIME=30 # Keep in clipboard for 30 seconds
```
- **PASSAGE_GENERATED_LENGTH** - Default length for generated passwords (default: 25)
```bash
export PASSAGE_GENERATED_LENGTH=32 # Generate 32-character passwords by default
```
- **EDITOR** - Editor to use for `passage edit` command (default: `vi`)
```bash
export EDITOR=nano # Use nano instead of vi
```
**Note:** For complete documentation of all environment variables, see the [manpage](#manpage) or run `man passage` after installation.
## Git Integration
Initialize git repository:
```bash
passage git init
```
All password operations automatically commit to git (if initialized).
## Security
**IMPORTANT**: PassAGE uses a master password model. Every operation requires the master password set during `passage init`.
### Quick Security Overview
- **Master password**: Single password protects all stored passwords (never stored in plaintext)
- **AGE Scrypt encryption**: Industry-standard passphrase encryption
- **Argon2id verification**: Master password verified using Argon2id hash (memory-hard, resistant to brute force)
- **File permissions**: Store directory uses 0700, sensitive files use 0600
### Best Practices
1. Choose a strong master password (it cannot be recovered if forgotten)
2. Use full disk encryption
3. Back up your password store directory
4. See [SECURITY.md](SECURITY.md) for detailed security information
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Documentation
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions and troubleshooting
- **[SECURITY.md](SECURITY.md)** - Security implementation details
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - How to contribute to the project
- **Manpage** - Run `man passage` after installation (source: `passage.1.md`)
## Project Structure
```
PassAGE/
├── main.go # Entry point, command routing
├── commands.go # Command implementations
├── store.go # Core store operations
├── security.go # Security utilities (path validation, etc.)
├── memory.go # Secure memory management
├── clipboard.go # Clipboard operations (Linux X11/Wayland)
├── backup.go # Backup/restore functionality
├── go.mod # Go module definition
├── Makefile # Build and install targets
├── passage.1.md # Manpage source
└── *.md # Documentation files
```
## License
This project uses AGE encryption. See LICENSE file for details.
PassAGE is a command-line password manager that uses AGE (Actually Good Encryption) for secure password
storage.

View File

@ -1,42 +0,0 @@
# Security Considerations for PassAGE
This document describes PassAGE's security model and implementation details.
## Current Implementation
PassAGE uses a master password model with AGE's passphrase encryption (Scrypt).
### Master Password Storage
The master password is **never stored in plaintext**. Instead:
1. **Argon2id hash**: A one-way hash is stored in `.master-pass` file in the password store directory
- Uses Argon2id (winner of Password Hashing Competition)
- Parameters: 3 iterations, 32MB memory, 4 threads, 32-byte output
- Includes random salt (16 bytes) for each password
- Format: `salt:hash` (base64 encoded)
2. **Verification**: When you enter the master password:
- A new hash is computed with the same salt
- Compared with stored hash using constant-time comparison
- If match, password is used for encryption/decryption
3. **Security properties**:
- **Memory-hard**: Resistant to GPU/ASIC attacks
- **Slow by design**: Makes brute force attacks expensive
- **Salt**: Prevents rainbow table attacks
- **One-way**: Hash cannot be reversed to get password
### Encryption
All passwords are encrypted using:
- **AGE Scrypt encryption**: Industry-standard passphrase encryption
- **Master password**: Used directly for encryption (not stored)
- **File format**: AGE v1 encrypted files (`.passage` extension)
### Security Model
- Master password protects all stored passwords
- Hash file is only for verification (cannot recover password)
- Full disk encryption recommended for additional protection
- File permissions: `.master-pass` stored with 0600 permissions

306
backup.go
View File

@ -1,306 +0,0 @@
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)
}

View File

@ -1,78 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
)
// clearClipboardAfter clears the clipboard after a delay
// Runs in a goroutine so it doesn't block
func clearClipboardAfter(duration time.Duration) {
go func() {
time.Sleep(duration)
clearClipboard()
}()
}
// clearClipboard clears the clipboard
// Tries both Wayland and X11 - whichever is available
func clearClipboard() {
var cmd *exec.Cmd
if os.Getenv("WAYLAND_DISPLAY") != "" {
cmd = exec.Command("wl-copy", "--clear")
} else if os.Getenv("DISPLAY") != "" {
// X11 - clear by copying /dev/null (hacky but works)
cmd = exec.Command("xclip", "-selection", "clipboard", "/dev/null")
} else {
return // No clipboard available, nothing to clear
}
cmd.Run() // Best effort - if it fails, oh well
}
// clipToClipboard copies text to clipboard and sets up auto-clear
func clipToClipboard(text, path string) {
clipTime := 10
if env := os.Getenv("PASSAGE_CLIP_TIME"); env != "" {
if n, err := fmt.Sscanf(env, "%d", &clipTime); err != nil || n != 1 || clipTime < 0 || clipTime > 3600 {
clipTime = 10 // Reset to default on invalid input
}
}
// Try xclip (X11) or wl-copy (Wayland)
var cmd *exec.Cmd
if os.Getenv("WAYLAND_DISPLAY") != "" {
cmd = exec.Command("wl-copy")
} else if os.Getenv("DISPLAY") != "" {
cmd = exec.Command("xclip", "-selection", "clipboard")
} else {
fmt.Fprintf(os.Stderr, "Error: no clipboard available (needs X11 or Wayland)\n")
os.Exit(1)
}
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to copy to clipboard: %v\n", err)
os.Exit(1)
}
fmt.Printf("Copied %s to clipboard. Will clear in %d seconds.\n", path, clipTime)
// Set up signal handler to clear clipboard if user hits Ctrl+C
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Auto-clear after the timeout
clearClipboardAfter(time.Duration(clipTime) * time.Second)
// Also clear if interrupted - don't leave passwords lying around
go func() {
<-sigChan
clearClipboard()
os.Exit(0)
}()
}

File diff suppressed because it is too large Load Diff

11
go.mod
View File

@ -1,11 +0,0 @@
module passage
go 1.21
require (
filippo.io/age v1.1.1
golang.org/x/crypto v0.21.0
golang.org/x/term v0.19.0
)
require golang.org/x/sys v0.19.0 // indirect

8
go.sum
View File

@ -1,8 +0,0 @@
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=

97
main.go
View File

@ -1,97 +0,0 @@
package main
import (
"fmt"
"os"
)
const version = "0.1.0"
const usage = `PassAGE - A modern password manager using AGE encryption
Usage:
passage init [--path=subfolder]
Initialize new password storage with a master password.
Prompts for master password (used to encrypt/decrypt all passwords).
passage [ls] [subfolder]
List passwords.
passage find pass-names...
List passwords that match pass-names.
passage [show] [--clip[=line-number]] pass-name
Show existing password and optionally put it on the clipboard.
passage grep [GREPOPTIONS] search-string
Search for password files containing search-string when decrypted.
passage insert [--multiline] [--force] pass-name
Insert new password. Optionally, the entry may be multiline.
passage edit pass-name
Insert a new password or edit an existing password using ${EDITOR:-vi}.
passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]
Generate a new password of pass-length (or 25 if unspecified).
passage rm [--recursive] [--force] pass-name
Remove existing password or directory, optionally forcefully.
passage mv [--force] old-path new-path
Renames or moves old-path to new-path, optionally forcefully.
passage cp [--force] old-path new-path
Copies old-path to new-path, optionally forcefully.
passage backup [--output=file.tar.gz]
Create a backup of the password store with integrity verification.
passage restore [--force] [--skip-verify] backup-file.tar.gz
Restore a backup of the password store with integrity verification.
passage git git-command-args...
If the password store is a git repository, execute a git command.
passage help
Show this text.
passage version
Show version information.
Environment variables:
PASSAGE_DIR Path to the password store (default: ~/.passage-store)
PASSAGE_CLIP_TIME Time in seconds to keep password in clipboard (default: 10)
PASSAGE_GENERATED_LENGTH Default length for generated passwords (default: 25)
`
func main() {
if len(os.Args) < 2 {
cmdShow([]string{})
return
}
command := os.Args[1]
args := os.Args[2:]
switch command {
case "init":
cmdInit(args)
case "help", "--help", "-h":
fmt.Print(usage)
case "version", "--version", "-v":
fmt.Printf("PassAGE version %s\n", version)
case "show", "ls", "list":
cmdShow(args)
case "find", "search":
cmdFind(args)
case "grep":
cmdGrep(args)
case "insert", "add":
cmdInsert(args)
case "edit":
cmdEdit(args)
case "generate":
cmdGenerate(args)
case "delete", "rm", "remove":
cmdDelete(args)
case "rename", "mv":
cmdMove(args)
case "copy", "cp":
cmdCopy(args)
case "backup":
cmdBackup(args)
case "restore":
cmdRestore(args)
case "git":
cmdGit(args)
default:
// Try as show command
cmdShow(os.Args[1:])
}
}

View File

@ -1,68 +0,0 @@
package main
import (
"crypto/rand"
"unsafe"
)
// SecureBytes wraps a byte slice and provides secure clearing
// Because Go strings are immutable and we can't really clear them, this helps
type SecureBytes struct {
data []byte
}
// NewSecureBytes creates a new SecureBytes from a byte slice
func NewSecureBytes(data []byte) *SecureBytes {
return &SecureBytes{data: data}
}
// Bytes returns the underlying byte slice
func (sb *SecureBytes) Bytes() []byte {
return sb.data
}
// String returns the string representation (use with caution)
// This uses unsafe.Pointer magic - don't look too closely, it works
func (sb *SecureBytes) String() string {
return *(*string)(unsafe.Pointer(&sb.data))
}
// Clear securely zeros the memory
// Overwrites with random junk first (defense in depth), then zeros
// Not perfect (Go GC might move things around), but better than nothing
func (sb *SecureBytes) Clear() {
if sb == nil || sb.data == nil {
return
}
// Fill with random data first - makes memory dumps less useful
rand.Read(sb.data)
// Then zero it out
for i := range sb.data {
sb.data[i] = 0
}
sb.data = nil
}
// ClearBytes securely clears a byte slice
func ClearBytes(data []byte) {
if data == nil {
return
}
// Overwrite with random data first, then zeros
rand.Read(data)
for i := range data {
data[i] = 0
}
}
// ClearString attempts to clear a string (limited effectiveness in Go)
// Go strings are immutable, so this is mostly wishful thinking
// But hey, at least we tried
func ClearString(s *string) {
if s == nil {
return
}
// Best we can do - just set it to empty
// The original memory might still be around somewhere, but GC will get it eventually
*s = ""
}

View File

@ -1,265 +0,0 @@
% passage(1) PassAGE - A modern password manager using AGE encryption
% PassAGE developers
% 2024
# NAME
passage - A modern password manager using AGE encryption (PassAGE)
# SYNOPSIS
**passage** [*command*] [*options*] [*arguments*]
# DESCRIPTION
passage is a command-line password manager that uses AGE (Actually Good Encryption)
for secure password storage. It uses a master password model where a single password
protects all stored passwords.
All passwords are encrypted using AGE's Scrypt passphrase encryption and stored
in the password store directory (default: `~/.passage-store`).
# COMMANDS
## init
**passage init** [**--path**=*subfolder*]
Initialize a new password store with a master password. Prompts for a master
password that will be used to encrypt and decrypt all passwords.
**Options:**
- **--path**, **-p** *subfolder* - Initialize store in a subfolder
## show, ls, list
**passage** [**show**|**ls**|**list**] [*subfolder*]
Display a password or list passwords in a directory.
**Options:**
- **--clip**[=*line-number*], **-c**[=*line-number*] - Copy password (or specific line) to clipboard
## find, search
**passage find** *pass-names*...
Find passwords by name using pattern matching.
## grep
**passage grep** [*GREPOPTIONS*] *search-string*
Search for passwords containing *search-string* when decrypted.
## insert, add
**passage insert** [**--multiline**] [**--force**] *pass-name*
Insert a new password. Prompts for password input.
**Options:**
- **--multiline**, **-m** - Allow multiline input (press Ctrl+D when finished)
- **--force**, **-f** - Overwrite existing password without prompting
## edit
**passage edit** *pass-name*
Edit an existing password using the editor specified by the EDITOR environment
variable (default: vi).
## generate
**passage generate** [**--no-symbols**] [**--clip**] [**--in-place**|**--force**] *pass-name* [*pass-length*]
Generate a new cryptographically secure random password.
**Options:**
- **--no-symbols**, **-n** - Don't include symbols in generated password
- **--clip**, **-c** - Copy generated password to clipboard
- **--in-place**, **-i** - Replace first line of existing password
- **--force**, **-f** - Overwrite existing password without prompting
- *pass-length* - Length of password to generate (default: 25)
## rm, delete, remove
**passage rm** [**--recursive**] [**--force**] *pass-name*
Remove a password or directory.
**Options:**
- **--recursive**, **-r** - Recursively remove directory
- **--force**, **-f** - Remove without prompting
## mv, rename
**passage mv** [**--force**] *old-path* *new-path*
Move or rename a password.
**Options:**
- **--force**, **-f** - Overwrite destination without prompting
## cp, copy
**passage cp** [**--force**] *old-path* *new-path*
Copy a password.
**Options:**
- **--force**, **-f** - Overwrite destination without prompting
## backup
**passage backup** [**--output**=*file.tar.gz*]
Create a backup of the password store with integrity verification.
**Options:**
- **--output**, **-o** *file.tar.gz* - Output backup file (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)
The backup includes a SHA256 checksum file for verification.
## restore
**passage restore** [**--force**] [**--skip-verify**] *backup-file.tar.gz*
Restore a backup of the password store.
**Options:**
- **--force**, **-f** - Overwrite existing store without prompting
- **--skip-verify**, **-s** - Skip checksum verification (not recommended)
## git
**passage git** *git-command-args*...
Run git commands in the password store directory. Useful for version control
and syncing across devices.
**Examples:**
- **passage git init** - Initialize git repository
- **passage git push** - Push changes to remote
- **passage git pull** - Pull changes from remote
## help
**passage help**
Show usage information.
## version
**passage version**
Show version information.
# ENVIRONMENT VARIABLES
**PASSAGE_DIR**
Path to the password store directory. Defaults to `~/.passage-store` if not set.
**PASSAGE_CLIP_TIME**
Time in seconds to keep password in clipboard before auto-clearing. Defaults to 10 seconds.
**PASSAGE_GENERATED_LENGTH**
Default length for generated passwords. Defaults to 25 if not set.
**EDITOR**
Editor to use for the **edit** command. Defaults to `vi` if not set.
**HOME**
Home directory path. Used as base for default store location.
**WAYLAND_DISPLAY**
If set, indicates Wayland display is available. Used for clipboard operations.
**DISPLAY**
If set, indicates X11 display is available. Used for clipboard operations.
# FILES
**~/.passage-store/**
Default password store directory. Contains encrypted password files (`.passage` extension)
and the master password hash file (`.master-pass`).
**~/.passage-store/.master-pass**
Stores Argon2id hash of master password for verification. Never contains the actual password.
# EXAMPLES
Initialize a new password store:
$ passage init
Initializing password store...
Enter master password:
Confirm master password:
Password store initialized
Add a password:
$ passage insert example.com
Enter master password:
Enter password for example.com:
Password for example.com added to store.
Show a password:
$ passage show example.com
Enter master password:
mypassword123
Generate a password:
$ passage generate example.com 32
Enter master password:
The generated password for example.com is:
xK9#mP2$vL8@nQ4&wR7!tY5*uI3^oE6
Copy password to clipboard:
$ passage show --clip example.com
Enter master password:
Copied example.com to clipboard. Will clear in 10 seconds.
Create a backup:
$ passage backup
Enter master password:
Backup created: passage-backup-20240101-120000.tar.gz
Files backed up: 15
Checksum: a1b2c3d4e5f6...
Checksum file: passage-backup-20240101-120000.tar.gz.sha256
# SECURITY
PassAGE uses AGE encryption with Scrypt passphrase encryption for all password files.
The master password is verified using Argon2id hashing, which is memory-hard and
resistant to brute-force attacks.
**Important security notes:**
- The master password is never stored in plaintext
- All password files are encrypted individually
- File permissions are set to 0600 (files) and 0700 (directories)
- Clipboard is automatically cleared after the timeout period
- Passwords are cleared from memory when possible
For detailed security information, see **SECURITY.md** in the PassAGE source code.
# SEE ALSO
**age**(1), **git**(1)
# BUGS
Report bugs at the project repository
# AUTHOR
PassAGE developers
# COPYRIGHT
This project uses AGE encryption. See LICENSE file for details.

View File

@ -1,163 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// checkSneakyPaths prevents path traversal attacks
// Because users will try to break things, and we'd rather not let them
func checkSneakyPaths(path string) error {
if path == "" {
return nil // Empty is fine
}
// Classic path traversal attempt - catch it early
if strings.Contains(path, "..") {
return fmt.Errorf("error: path contains '..' - path traversal not allowed")
}
// No absolute paths either - keep everything relative
if filepath.IsAbs(path) {
return fmt.Errorf("error: absolute paths not allowed")
}
// Check if path would escape store directory
storeDir := getStoreDir()
absPath, err := filepath.Abs(filepath.Join(storeDir, path))
if err != nil {
return fmt.Errorf("error: invalid path: %w", err)
}
absStoreDir, err := filepath.Abs(storeDir)
if err != nil {
return fmt.Errorf("error: invalid store directory: %w", err)
}
// Ensure the resolved path is within the store directory
relPath, err := filepath.Rel(absStoreDir, absPath)
if err != nil {
return fmt.Errorf("error: path outside store directory")
}
if strings.HasPrefix(relPath, "..") {
return fmt.Errorf("error: path escapes store directory")
}
return nil
}
// sanitizePath cleans and validates a password path
func sanitizePath(path string) (string, error) {
if err := checkSneakyPaths(path); err != nil {
return "", err
}
// Clean up the path - normalize separators and such
cleaned := filepath.Clean(path)
// Trim slashes - they're not needed
cleaned = strings.Trim(cleaned, "/")
// Reasonable length limit - 4KB should be enough for anyone (famous last words)
const maxPathLength = 4096
if len(cleaned) > maxPathLength {
return "", fmt.Errorf("error: path too long (max %d characters)", maxPathLength)
}
return cleaned, nil
}
// maxFileSize limits the size of files we'll read into memory
// 10MB seems reasonable - if you need bigger passwords, maybe rethink your life choices
const maxFileSize = 10 * 1024 * 1024
// checkFileSize checks if a file is within size limits
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxFileSize {
return fmt.Errorf("error: file too large (max %d bytes)", maxFileSize)
}
return nil
}
// maxPasswordLength limits password length to prevent DoS
// 1MB should be enough for even the most paranoid password
const maxPasswordLength = 1024 * 1024
// checkPasswordLength validates password length
func checkPasswordLength(password string) error {
if len(password) > maxPasswordLength {
return fmt.Errorf("error: password too long (max %d bytes)", maxPasswordLength)
}
return nil
}
// readLimited reads up to maxBytes from a reader to prevent resource exhaustion
func readLimited(r io.Reader, maxBytes int64) ([]byte, error) {
limitedReader := io.LimitReader(r, maxBytes)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, err
}
// Check if we hit the limit (would mean file is larger than max)
if int64(len(data)) == maxBytes {
// Try to read one more byte to see if there's more
var buf [1]byte
if n, _ := r.Read(buf[:]); n > 0 {
return nil, fmt.Errorf("error: file too large (max %d bytes)", maxBytes)
}
}
return data, nil
}
// createSecureTempFile creates a temporary file in a secure location
// Tries /dev/shm first (RAM-backed, faster, more secure), falls back to system temp
func createSecureTempFile(prefix, suffix string) (string, error) {
// Try /dev/shm - it's RAM-backed so it's faster and doesn't touch disk
// Not available on all systems, but worth checking
if dir := "/dev/shm"; isWritable(dir) {
tmpFile, err := os.CreateTemp(dir, prefix)
if err == nil {
tmpFile.Close()
return tmpFile.Name(), nil
}
// If it fails, just continue to fallback
}
// Fall back to whatever the OS thinks is a good temp directory
tmpFile, err := os.CreateTemp("", prefix)
if err != nil {
return "", err
}
tmpFile.Close()
return tmpFile.Name(), nil
}
// isWritable checks if a directory is writable
func isWritable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if !info.IsDir() {
return false
}
// Try to create a test file
testFile := filepath.Join(path, ".passage-write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}

346
store.go
View File

@ -1,346 +0,0 @@
package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"filippo.io/age"
"golang.org/x/crypto/argon2"
"golang.org/x/term"
)
const (
defaultStoreDir = ".passage-store"
passageExt = ".passage"
masterPassFile = ".master-pass" // Just a hash, not the actual password (obviously)
)
// getStoreDir returns the password store directory path
// Tries to be smart about where to put things, but falls back gracefully
func getStoreDir() string {
if dir := os.Getenv("PASSAGE_DIR"); dir != "" {
// Paranoid check for path traversal - you never know what users will try
if strings.Contains(dir, "..") {
// Nope, not today. Fall back to default.
return filepath.Join(os.Getenv("HOME"), defaultStoreDir)
}
// Make sure we have an absolute path
if !filepath.IsAbs(dir) {
// Relative paths are fine, just anchor them to HOME
return filepath.Join(os.Getenv("HOME"), dir)
}
// Double-check that even absolute paths don't escape HOME
// (because why would you want your passwords outside HOME anyway?)
home := os.Getenv("HOME")
if home != "" {
rel, err := filepath.Rel(home, dir)
if err != nil || strings.HasPrefix(rel, "..") {
// Nice try, but no. Default it is.
return filepath.Join(home, defaultStoreDir)
}
}
return dir
}
// No custom dir set, use the default location
home, err := os.UserHomeDir()
if err != nil {
// Can't get home dir? Just use current directory I guess
return defaultStoreDir
}
return filepath.Join(home, defaultStoreDir)
}
// getMasterPassPath returns the path to the master password verification file
func getMasterPassPath(dir string) string {
return filepath.Join(dir, masterPassFile)
}
// getMasterPassword prompts for the master password and returns SecureBytes
// Tries to hide input if possible, but falls back gracefully for pipes/scripts
func getMasterPassword(prompt string) (*SecureBytes, error) {
if prompt == "" {
prompt = "Enter master password: "
}
// Try to hide the password if we're in a real terminal
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprint(os.Stderr, "\n")
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
return NewSecureBytes(password), nil
}
// Not a terminal (probably a pipe or script) - can't hide it, but at least it works
reader := bufio.NewReader(os.Stdin)
fmt.Fprint(os.Stderr, prompt)
password, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
trimmed := strings.TrimSpace(password)
return NewSecureBytes([]byte(trimmed)), nil
}
// checkStoreInitialized checks if the store is initialized
func checkStoreInitialized(dir string) bool {
masterPassPath := getMasterPassPath(dir)
_, err := os.Stat(masterPassPath)
return err == nil
}
// getRecipientForEncryption returns a ScryptRecipient for encrypting with master password
func getRecipientForEncryption(masterPassword []byte) (age.Recipient, error) {
return age.NewScryptRecipient(string(masterPassword))
}
// getIdentityForDecryption returns a ScryptIdentity for decrypting with master password
func getIdentityForDecryption(masterPassword []byte) (age.Identity, error) {
return age.NewScryptIdentity(string(masterPassword))
}
// hashPassword creates an Argon2id hash of the password for verification
// Returns base64-encoded salt:hash
func hashPassword(password []byte) (string, error) {
// Generate random salt - makes each hash unique even for same password
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
// Argon2id with reasonable defaults:
// - 3 iterations (fast enough, slow enough for attackers)
// - 32 MB memory (makes GPU attacks expensive)
// - 4 threads (parallelism)
// - 32 bytes output (standard hash size)
hash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
// Encode as base64: salt:hash
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
return saltB64 + ":" + hashB64, nil
}
// verifyMasterPassword checks if the provided password matches the stored hash
func verifyMasterPassword(storeDir string, password []byte) (bool, error) {
masterPassPath := getMasterPassPath(storeDir)
data, err := os.ReadFile(masterPassPath)
if err != nil {
return false, err
}
stored := strings.TrimSpace(string(data))
// Parse stored salt:hash
parts := strings.Split(stored, ":")
if len(parts) != 2 {
// Legacy SHA256 format - try to verify and migrate
return verifyLegacySHA256(password, stored)
}
saltB64 := parts[0]
expectedHashB64 := parts[1]
// Decode salt and expected hash
salt, err := base64.RawStdEncoding.DecodeString(saltB64)
if err != nil {
return false, fmt.Errorf("failed to decode salt: %w", err)
}
expectedHash, err := base64.RawStdEncoding.DecodeString(expectedHashB64)
if err != nil {
return false, fmt.Errorf("failed to decode hash: %w", err)
}
// Compute hash with same parameters
computedHash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
// Constant-time comparison
return constantTimeCompare(computedHash, expectedHash), nil
}
// verifyLegacySHA256 verifies against old SHA256 hashes (for migration)
// Kept for backwards compatibility with stores created before Argon2id migration
func verifyLegacySHA256(password []byte, storedHash string) (bool, error) {
// Old stores used SHA256 - not ideal but we support it for migration
hash := sha256Sum(password)
providedHash := hex.EncodeToString(hash[:])
// Constant-time comparison - can't let attackers time their way in
return constantTimeStringCompare(providedHash, storedHash), nil
}
// sha256Sum computes SHA256 hash (for legacy verification only)
func sha256Sum(data []byte) [32]byte {
return sha256.Sum256(data)
}
// constantTimeCompare performs constant-time comparison of two byte slices
func constantTimeCompare(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// constantTimeStringCompare performs constant-time comparison of two strings
func constantTimeStringCompare(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// storeMasterPasswordHash stores an Argon2id hash of the master password for verification
func storeMasterPasswordHash(storeDir string, password []byte) error {
masterPassPath := getMasterPassPath(storeDir)
hash, err := hashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
return os.WriteFile(masterPassPath, []byte(hash), 0600)
}
// encryptFile encrypts a file using AGE with passphrase encryption
// Uses AGE's scrypt encryption - simple but effective
func encryptFile(inputPath, outputPath string, masterPassword []byte) error {
recipient, err := getRecipientForEncryption(masterPassword)
if err != nil {
return fmt.Errorf("failed to create recipient: %w", err)
}
var input io.Reader
if inputPath == "" || inputPath == "-" {
input = os.Stdin
} else {
file, err := os.Open(inputPath)
if err != nil {
return err
}
defer file.Close()
input = file
}
output, err := os.Create(outputPath)
if err != nil {
return err
}
defer output.Close()
encrypted, err := age.Encrypt(output, recipient)
if err != nil {
return fmt.Errorf("failed to create encrypted writer: %w", err)
}
if _, err := io.Copy(encrypted, input); err != nil {
return fmt.Errorf("failed to encrypt data: %w", err)
}
if err := encrypted.Close(); err != nil {
return fmt.Errorf("failed to finalize encryption: %w", err)
}
return nil
}
// decryptFile decrypts an AGE-encrypted file using master password
// If outputPath is empty or "-", returns a reader for the decrypted content
// If outputPath is specified, writes decrypted content to file and returns nil reader
func decryptFile(inputPath, outputPath string, masterPassword []byte) (io.Reader, error) {
identity, err := getIdentityForDecryption(masterPassword)
if err != nil {
return nil, fmt.Errorf("failed to create identity: %w", err)
}
var input io.Reader
var file *os.File
if inputPath == "" || inputPath == "-" {
input = os.Stdin
} else {
file, err = os.Open(inputPath)
if err != nil {
return nil, err
}
input = file
}
decrypted, err := age.Decrypt(input, identity)
if err != nil {
if file != nil {
file.Close()
}
return nil, fmt.Errorf("failed to decrypt: %w", err)
}
// If output path specified, write to file and close everything
if outputPath != "" && outputPath != "-" {
outputFile, err := os.Create(outputPath)
if err != nil {
if file != nil {
file.Close()
}
return nil, err
}
defer outputFile.Close()
if file != nil {
defer file.Close()
}
if _, err := io.Copy(outputFile, decrypted); err != nil {
return nil, fmt.Errorf("failed to write decrypted data: %w", err)
}
return nil, nil
}
// Return a reader that keeps the file open until reading is complete
// This was a fun bug to track down - file was closing too early!
// Now fileReader handles cleanup properly
if file == nil {
// Reading from stdin - no file to manage
return decrypted, nil
}
return &fileReader{
file: file,
reader: decrypted,
}, nil
}
// fileReader wraps a reader and keeps the underlying file open until reading completes
type fileReader struct {
file *os.File
reader io.Reader
}
func (fr *fileReader) Read(p []byte) (n int, err error) {
if fr == nil || fr.reader == nil {
return 0, io.EOF
}
n, err = fr.reader.Read(p)
if err == io.EOF || err != nil {
// Clean up when we're done - don't leak file handles
if fr.file != nil {
fr.file.Close()
fr.file = nil
}
fr.reader = nil
}
return n, err
}