commit 3552db50c2bd73e57144b97ee49eb6f159dd6018 Author: fraggle Date: Sun Jan 11 18:48:01 2026 -0400 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9188139 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: Build and Test + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.21', '1.22'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Verify dependencies + run: go mod verify + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v -o passage . + + - name: Test + run: go test -v ./... + + - name: Build release version + run: go build -trimpath -ldflags "-s -w" -o passage-release . + + - name: Check manpage builds (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y pandoc + make man diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92d1f64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..168fd2c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to PassAGE will be documented in this file. + +## [0.1.0] - Initial Release + +### Features +- PassAGE password manager using AGE encryption +- Master password model: single password protects all stored passwords +- 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 using crypto/rand + +### Technical Details +- Written in Go +- Uses filippo.io/age library for encryption +- Compatible with AGE v1 specification +- Argon2id parameters: 3 iterations, 32MB memory, 4 threads, 32-byte output diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md new file mode 100644 index 0000000..65ada64 --- /dev/null +++ b/CODE_STRUCTURE.md @@ -0,0 +1,112 @@ +# Code Structure + +This document explains the organization of PassAGE's source code. + +## File Overview + +### Core Application Files + +- **`main.go`** - Application entry point + - Parses command-line arguments + - Routes commands to appropriate handlers + - Displays usage and version information + +- **`commands.go`** - Command implementations + - All user-facing commands (init, show, insert, edit, generate, etc.) + - Command-line flag parsing + - User interaction and output formatting + +- **`store.go`** - Core store operations + - Password store directory management + - Master password handling (hashing, verification) + - AGE encryption/decryption functions + - File I/O operations + +### Security & Utilities + +- **`security.go`** - Security utilities + - Path traversal protection + - Input validation and sanitization + - Resource limits (file size, password length) + - Secure temporary file creation + +- **`memory.go`** - Secure memory management + - `SecureBytes` type for sensitive data + - Memory clearing functions + - Prevents passwords from lingering in memory + +- **`clipboard.go`** - Clipboard operations + - Copy passwords to clipboard + - Auto-clear clipboard after timeout + - Signal handling for cleanup + +- **`backup.go`** - Backup and restore + - Create compressed backups with checksums + - Restore backups with integrity verification + - Path validation during restore + +## Code Flow + +### Initialization Flow +1. User runs `passage init` +2. `cmdInit()` prompts for master password +3. Password is hashed with Argon2id +4. Hash stored in `.master-pass` file +5. Store directory created with proper permissions + +### Password Storage Flow +1. User runs `passage insert example.com` +2. `getMasterPasswordForOperation()` prompts and verifies master password +3. Password stored in `SecureBytes` (cleared after use) +4. Password encrypted with AGE Scrypt encryption +5. Encrypted file saved as `example.com.passage` + +### Password Retrieval Flow +1. User runs `passage show example.com` +2. Master password verified +3. Encrypted file decrypted using AGE +4. Decrypted content displayed or copied to clipboard +5. Master password cleared from memory + +## Key Design Decisions + +### Master Password Model +- Single password protects all passwords +- Verified using Argon2id hash (memory-hard) +- Never stored in plaintext +- Required for all operations + +### File Organization +- All files in root directory (simple, standard for Go CLI tools) +- Clear separation of concerns by file +- Each file has a specific purpose + +### Security Features +- Constant-time password comparisons +- Secure memory clearing +- Path traversal protection +- Resource limits to prevent DoS +- File permissions (0600/0700) + +### Error Handling +- Clear error messages +- Graceful fallbacks where appropriate +- Proper cleanup on errors + +## Dependencies + +- **filippo.io/age** - AGE encryption library +- **golang.org/x/crypto** - Argon2id hashing +- **golang.org/x/term** - Secure password input + +## Testing + +Run tests with: +```bash +go test ./... +``` + +Run with race detector: +```bash +go test -race ./... +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..577818b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to PassAGE + +Thank you for your interest in contributing to PassAGE! + +## Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally +3. **Create a branch** for your changes +4. **Make your changes** and test them +5. **Submit a pull request** + +## Development Setup + +1. **Fork and clone the repository:** + ```bash + git clone https://git.fraggle.lol/fraggle/PassAGE.git + 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. + +## Repository Setup + +If you're setting up the repository for the first time, see [SETUP.md](SETUP.md) for git initialization instructions. + +## Questions? + +Feel free to open an issue for questions or discussions. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..f1ef79a --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,175 @@ +# Installation Guide + +Complete installation instructions for PassAGE. + +**Quick Install:** `go install git.fraggle.lol/fraggle/PassAGE@latest` + +## Quick Install + +### Using Go (Recommended) + +```bash +go install git.fraggle.lol/fraggle/PassAGE@latest +``` + +### From Source + +```bash +# Clone repository +git clone https://git.fraggle.lol/fraggle/PassAGE.git +cd PassAGE + +# Build and install +make install + +# Or for user installation (no sudo) +make install-user +``` + +## System Requirements + +- **Go 1.21 or later** - Required for building from source +- **Linux/macOS/Unix-like system** - Windows support may vary +- **X11 or Wayland** - For clipboard support (optional) + +## Build Dependencies + +### Required +- Go toolchain (1.21+) + +### Optional +- **pandoc** - For building manpages + - Debian/Ubuntu: `sudo apt-get install pandoc` + - macOS: `brew install pandoc` + - Or use `go-md2man`: `go install github.com/cpuguy83/go-md2man/v2@latest` + +## Installation Steps + +### 1. Clone the Repository + +```bash +git clone https://git.fraggle.lol/fraggle/PassAGE.git +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` +- macOS: Clipboard works natively +- Arch: `sudo pacman -S xclip` or `sudo pacman -S wl-clipboard` + +## Uninstallation + +```bash +# Remove binary +sudo rm /usr/local/bin/passage +# or +rm ~/.local/bin/passage + +# Remove manpage +sudo rm /usr/local/share/man/man1/passage.1 +# or +rm ~/.local/share/man/man1/passage.1 + +# Update man database +sudo mandb +# or +mandb ~/.local/share/man +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..86c0f2b --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a52d41f --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# PassAGE + +A modern password manager using AGE encryption. + +**Repository:** [git.fraggle.lol/fraggle/PassAGE](https://git.fraggle.lol/fraggle/PassAGE) + +**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 +- **Cross-platform**: Works on Linux, macOS, and other 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 +- **Git** - For cloning the repository +- **pandoc** or **go-md2man** (optional) - For building manpages + +### Build from Source + +```bash +# Clone the repository +git clone https://git.fraggle.lol/fraggle/PassAGE.git +cd PassAGE + +# 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 git.fraggle.lol/fraggle/PassAGE@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](https://git.fraggle.lol/fraggle/PassAGE/releases). + +## 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 +- **[CHANGELOG.md](CHANGELOG.md)** - Version history and changes +- **[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 +├── backup.go # Backup/restore functionality +├── go.mod # Go module definition +├── Makefile # Build and install targets +├── passage.1.md # Manpage source +└── *.md # Documentation files +``` + +For detailed code structure, see [CODE_STRUCTURE.md](CODE_STRUCTURE.md). + +## License + +This project uses AGE encryption. See LICENSE file for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1f49e1f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# 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 diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..d90c18c --- /dev/null +++ b/SETUP.md @@ -0,0 +1,123 @@ +# Repository Setup Guide + +**For Contributors:** This guide helps you set up the PassAGE repository for git. + +**Note:** This is for developers setting up the repository. End users should see [INSTALL.md](INSTALL.md). + +## Initial Git Setup + +```bash +# Initialize git repository +git init + +# Add all files (respects .gitignore) +git add . + +# Create initial commit +git commit -m "Initial commit: PassAGE password manager" + +# Add remote repository (replace with your actual repo URL) +git remote add origin https://git.fraggle.lol/fraggle/PassAGE.git + +# Push to remote +git push -u origin main +``` + +## What Gets Committed + +### Source Files (Committed) +- All `.go` files (source code) +- `go.mod` and `go.sum` (dependency management) +- `Makefile` (build configuration) +- `passage.1.md` (manpage source) +- All `.md` documentation files + +### Excluded Files (.gitignore) +- `passage` (compiled binary) +- `passage.1` (compiled manpage) +- Build artifacts +- IDE files +- Test coverage files + +## Verifying Build Readiness + +Before pushing, verify the repository can build on a clean system: + +```bash +# Clean everything +make clean +rm -rf vendor/ + +# Verify dependencies +go mod verify +go mod tidy + +# Test build +go build -o passage . + +# Test installation +make install-user + +# Clean up +make clean +``` + +## CI/CD Setup + +The repository includes a GitHub Actions workflow (`.github/workflows/build.yml`) that: +- Tests builds on Linux, macOS, and Windows +- Tests with multiple Go versions +- Verifies dependencies +- Builds release binaries + +This will run automatically on push/PR. + +## Repository Structure + +``` +passage/ +├── .gitignore # Git ignore rules +├── .github/ +│ └── workflows/ +│ └── build.yml # CI/CD workflow +├── *.go # Source code files +├── go.mod # Go module definition +├── go.sum # Dependency checksums +├── Makefile # Build and install targets +├── passage.1.md # Manpage source +├── README.md # Main documentation +├── INSTALL.md # Installation guide +├── CONTRIBUTING.md # Contribution guidelines +├── SECURITY.md # Security documentation +└── CHANGELOG.md # Version history +``` + +## Next Steps + +1. **Repository URLs are already configured:** + - All documentation points to `https://git.fraggle.lol/fraggle/PassAGE` + - Update GitHub Actions workflow if using GitHub instead + +2. **Create GitHub repository:** + - Create a new repository on GitHub + - Don't initialize with README (we already have one) + +3. **Push code:** + ```bash + git push -u origin main + ``` + +4. **Set up releases:** + - Create tags for versions: `git tag v0.1.0` + - Push tags: `git push --tags` + - GitHub Actions will build release binaries + +## Building on Any System + +The repository is designed to build on any system with: +- Go 1.21+ installed +- Standard Unix tools (make, etc.) + +Dependencies are managed through `go.mod` and will be downloaded automatically. + +For manpage building, users need `pandoc` or `go-md2man` (optional). diff --git a/backup.go b/backup.go new file mode 100644 index 0000000..0f05991 --- /dev/null +++ b/backup.go @@ -0,0 +1,306 @@ +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) +} diff --git a/clipboard.go b/clipboard.go new file mode 100644 index 0000000..db990f0 --- /dev/null +++ b/clipboard.go @@ -0,0 +1,78 @@ +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) + }() +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..25c073c --- /dev/null +++ b/commands.go @@ -0,0 +1,1213 @@ +package main + +import ( + "bufio" + "crypto/rand" + "flag" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/term" +) + +// cmdInit initializes a new password store with a master password +// Sets up everything needed to start storing passwords +func cmdInit(args []string) { + fs := flag.NewFlagSet("init", flag.ExitOnError) + var pathFlag string + fs.StringVar(&pathFlag, "path", "", "subfolder path") + fs.StringVar(&pathFlag, "p", "", "subfolder path (short)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if fs.NArg() > 0 { + fmt.Fprintf(os.Stderr, "Error: passage init does not take arguments. It will prompt for a master password.\n") + fmt.Fprintf(os.Stderr, "Usage: passage init [--path=subfolder]\n") + os.Exit(1) + } + + storeDir := getStoreDir() + targetDir := storeDir + if pathFlag != "" { + if err := checkSneakyPaths(pathFlag); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + pathFlag, err := sanitizePath(pathFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + targetDir = filepath.Join(storeDir, pathFlag) + } + + if err := os.MkdirAll(targetDir, 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + // Check if already initialized + if checkStoreInitialized(targetDir) { + fmt.Fprintf(os.Stderr, "Error: password store already initialized at %s\n", targetDir) + os.Exit(1) + } + + // Get the master password (twice, because typos are annoying) + fmt.Fprintf(os.Stderr, "Initializing password store...\n") + masterPassword, err := getMasterPassword("Enter master password: ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer masterPassword.Clear() // Always clean up + + // Make sure they typed it right + confirmPassword, err := getMasterPassword("Confirm master password: ") + if err != nil { + masterPassword.Clear() + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer confirmPassword.Clear() + + // Constant-time comparison - can't let attackers guess passwords by timing + if !constantTimeCompare(masterPassword.Bytes(), confirmPassword.Bytes()) { + masterPassword.Clear() + confirmPassword.Clear() + fmt.Fprintf(os.Stderr, "Error: passwords do not match\n") + os.Exit(1) + } + + // Store password hash for verification + if err := storeMasterPasswordHash(targetDir, masterPassword.Bytes()); err != nil { + masterPassword.Clear() + confirmPassword.Clear() + fmt.Fprintf(os.Stderr, "Error: failed to store password hash: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Password store initialized%s\n", + func() string { + if pathFlag != "" { + return fmt.Sprintf(" (%s)", pathFlag) + } + return "" + }()) + + // Initialize git if requested or if .git already exists + gitDir := filepath.Join(storeDir, ".git") + if _, err := os.Stat(gitDir); err == nil { + masterPassPath := getMasterPassPath(targetDir) + gitAddFile(masterPassPath, fmt.Sprintf("Initialize password store%s.", + func() string { + if pathFlag != "" { + return fmt.Sprintf(" (%s)", pathFlag) + } + return "" + }())) + } +} + +// getMasterPasswordForOperation prompts for master password and verifies store is initialized +func getMasterPasswordForOperation(storeDir string) (*SecureBytes, error) { + if !checkStoreInitialized(storeDir) { + return nil, fmt.Errorf("password store not initialized. Run 'passage init' first") + } + + masterPassword, err := getMasterPassword("Enter master password: ") + if err != nil { + return nil, err + } + + // Check if the password is correct + valid, err := verifyMasterPassword(storeDir, masterPassword.Bytes()) + if err != nil { + // Hash file missing or corrupted - might be an old store, warn but allow + fmt.Fprintf(os.Stderr, "Warning: could not verify password hash: %v\n", err) + } else if !valid { + // Wrong password - clear it and bail + masterPassword.Clear() + return nil, fmt.Errorf("incorrect master password") + } + + return masterPassword, nil +} + +// cmdShow displays a password +func cmdShow(args []string) { + fs := flag.NewFlagSet("show", flag.ExitOnError) + var clipFlag string + var clipLine int + fs.StringVar(&clipFlag, "clip", "", "copy to clipboard") + fs.StringVar(&clipFlag, "c", "", "copy to clipboard (short)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + storeDir := getStoreDir() + var path string + if fs.NArg() > 0 { + path = fs.Arg(0) + if err := checkSneakyPaths(path); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + path, _ = sanitizePath(path) // Already checked, ignore error + } + + passfile := filepath.Join(storeDir, path+passageExt) + + // Check if it's a directory + if info, err := os.Stat(filepath.Join(storeDir, path)); err == nil && info.IsDir() { + cmdList(path) + return + } + + if _, err := os.Stat(passfile); err != nil { + if path == "" { + // List root + cmdList("") + return + } + fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path) + os.Exit(1) + } + + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer masterPassword.Clear() + + decrypted, err := decryptFile(passfile, "", masterPassword.Bytes()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err) + os.Exit(1) + } + + // Check file size before reading + if err := checkFileSize(passfile); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + data, err := readLimited(decrypted, maxFileSize) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err) + os.Exit(1) + } + + lines := strings.Split(string(data), "\n") + if clipFlag != "" { + if clipFlag == "clip" || clipFlag == "c" { + clipLine = 1 + } else { + if n, err := fmt.Sscanf(clipFlag, "%d", &clipLine); err != nil || n != 1 || clipLine <= 0 { + fmt.Fprintf(os.Stderr, "Error: invalid clip line number\n") + os.Exit(1) + } + } + if clipLine < 1 || clipLine > len(lines) { + fmt.Fprintf(os.Stderr, "Error: line %d does not exist\n", clipLine) + os.Exit(1) + } + clipToClipboard(lines[clipLine-1], path) + } else { + fmt.Print(string(data)) + } +} + +// cmdList lists passwords in a directory +func cmdList(path string) { + if path != "" { + if err := checkSneakyPaths(path); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + path, _ = sanitizePath(path) // Already checked + } + storeDir := getStoreDir() + targetDir := storeDir + if path != "" { + targetDir = filepath.Join(storeDir, path) + } + + if path == "" { + fmt.Println("Password Store") + } else { + fmt.Println(path) + } + + entries, err := os.ReadDir(targetDir) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: password store is empty. Try \"passage init\"\n") + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + for _, entry := range entries { + if entry.IsDir() { + fmt.Printf("%s/\n", entry.Name()) + } else if strings.HasSuffix(entry.Name(), passageExt) { + name := strings.TrimSuffix(entry.Name(), passageExt) + if name != ".passage-id" { + fmt.Printf("%s\n", name) + } + } + } +} + +// cmdFind searches for passwords by name +func cmdFind(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: passage find pass-names...\n") + os.Exit(1) + } + + storeDir := getStoreDir() + // Validate all search terms for path traversal + for _, term := range args { + if err := checkSneakyPaths(term); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + } + terms := strings.Join(args, "|") + pattern := regexp.MustCompile("(?i)" + regexp.QuoteMeta(terms)) + + err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, passageExt) { + return nil + } + relPath, err := filepath.Rel(storeDir, path) + if err != nil { + return nil + } + name := strings.TrimSuffix(relPath, passageExt) + if pattern.MatchString(name) { + fmt.Println(name) + } + return nil + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// cmdGrep searches within password contents +func cmdGrep(args []string) { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "Usage: passage grep [GREPOPTIONS] search-string\n") + os.Exit(1) + } + + storeDir := getStoreDir() + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, passageExt) { + return nil + } + + decrypted, err := decryptFile(path, "", masterPassword.Bytes()) + if err != nil { + return nil // Skip files we can't decrypt + } + + // Check file size before reading + if err := checkFileSize(path); err != nil { + return nil // Skip files that are too large + } + + data, err := readLimited(decrypted, maxFileSize) + if err != nil { + return nil + } + + // Simple grep - just check if search string is in content + searchStr := args[len(args)-1] + if strings.Contains(string(data), searchStr) { + relPath, _ := filepath.Rel(storeDir, path) + name := strings.TrimSuffix(relPath, passageExt) + fmt.Printf("%s:\n", name) + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.Contains(line, searchStr) { + fmt.Printf(" %d: %s\n", i+1, line) + } + } + } + + return nil + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// cmdInsert inserts a new password +func cmdInsert(args []string) { + fs := flag.NewFlagSet("insert", flag.ExitOnError) + multiline := fs.Bool("multiline", false, "multiline entry") + multilineShort := fs.Bool("m", false, "multiline entry (short)") + force := fs.Bool("force", false, "overwrite without prompting") + forceShort := fs.Bool("f", false, "overwrite without prompting (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 insert [--multiline] [--force] pass-name\n") + os.Exit(1) + } + + path := fs.Arg(0) + storeDir := getStoreDir() + passfile := filepath.Join(storeDir, path+passageExt) + + if !*force && !*forceShort { + if _, err := os.Stat(passfile); err == nil { + fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + os.Exit(1) + } + } + } + + if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + var password string + if *multiline || *multilineShort { + fmt.Printf("Enter contents of %s and press Ctrl+D when finished:\n\n", path) + data, err := readLimited(os.Stdin, maxPasswordLength) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read input: %v\n", err) + os.Exit(1) + } + password = string(data) + if err := checkPasswordLength(password); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + } else { + fmt.Printf("Enter password for %s: ", path) + bytePassword, err := readPassword() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read password: %v\n", err) + os.Exit(1) + } + fmt.Println() + password = string(bytePassword) + } + + // Validate password length + if err := checkPasswordLength(password); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + // Create temporary file with password in secure location + tmpFile, err := createSecureTempFile("passage-insert-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer os.Remove(tmpFile) + + if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + + if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + + gitAddFile(passfile, fmt.Sprintf("Add given password for %s to store.", path)) + fmt.Printf("Password for %s added to store.\n", path) +} + +// cmdEdit edits a password +func cmdEdit(args []string) { + if len(args) != 1 { + fmt.Fprintf(os.Stderr, "Usage: passage edit pass-name\n") + os.Exit(1) + } + + path := args[0] + if err := checkSneakyPaths(path); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + path, err := sanitizePath(path) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + storeDir := getStoreDir() + passfile := filepath.Join(storeDir, path+passageExt) + + if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Create temporary file in secure location + tmpFile, err := createSecureTempFile("passage-edit-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer func() { + // Ensure cleanup even on panic + os.Remove(tmpFile) + }() + + // Decrypt existing file if it exists + if _, err := os.Stat(passfile); err == nil { + decrypted, err := decryptFile(passfile, "", masterPassword.Bytes()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err) + os.Exit(1) + } + + data, err := readLimited(decrypted, maxFileSize) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err) + os.Exit(1) + } + + if err := checkPasswordLength(string(data)); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + } + + // Edit file + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + // Paranoid check - don't let users inject commands through EDITOR + // Only simple editor names allowed, no paths or shell metacharacters + if strings.Contains(editor, "/") || strings.Contains(editor, "..") || strings.Contains(editor, "|") || strings.Contains(editor, "&") || strings.Contains(editor, ";") { + fmt.Fprintf(os.Stderr, "Error: invalid editor command\n") + os.Exit(1) + } + + cmd := exec.Command(editor, tmpFile) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: editor failed: %v\n", err) + os.Exit(1) + } + + // Check if file was modified + if _, err := os.Stat(tmpFile); err != nil { + fmt.Fprintf(os.Stderr, "Error: new password not saved\n") + os.Exit(1) + } + + // Validate edited content length + editedData, err := os.ReadFile(tmpFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read edited file: %v\n", err) + os.Exit(1) + } + if err := checkPasswordLength(string(editedData)); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + // Encrypt and save + if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + + action := "Add" + if _, err := os.Stat(passfile); err == nil { + action = "Edit" + } + + gitAddFile(passfile, fmt.Sprintf("%s password for %s using %s.", action, path, editor)) + fmt.Printf("Password for %s %sd.\n", path, strings.ToLower(action)) +} + +// cmdGenerate generates a new password +func cmdGenerate(args []string) { + fs := flag.NewFlagSet("generate", flag.ExitOnError) + noSymbols := fs.Bool("no-symbols", false, "don't use symbols") + noSymbolsShort := fs.Bool("n", false, "don't use symbols (short)") + clip := fs.Bool("clip", false, "copy to clipboard") + clipShort := fs.Bool("c", false, "copy to clipboard (short)") + force := fs.Bool("force", false, "overwrite without prompting") + forceShort := fs.Bool("f", false, "overwrite without prompting (short)") + inPlace := fs.Bool("in-place", false, "replace only first line") + inPlaceShort := fs.Bool("i", false, "replace only first line (short)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if fs.NArg() < 1 || fs.NArg() > 2 { + fmt.Fprintf(os.Stderr, "Usage: passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]\n") + os.Exit(1) + } + + path := fs.Arg(0) + if err := checkSneakyPaths(path); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + path, err := sanitizePath(path) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + length := 25 + if fs.NArg() == 2 { + if n, err := fmt.Sscanf(fs.Arg(1), "%d", &length); err != nil || n != 1 || length <= 0 { + fmt.Fprintf(os.Stderr, "Error: pass-length must be a positive number\n") + os.Exit(1) + } + // Prevent integer overflow and DoS + const maxLength = 10000 + if length > maxLength { + fmt.Fprintf(os.Stderr, "Error: pass-length too large (max %d)\n", maxLength) + os.Exit(1) + } + } + + storeDir := getStoreDir() + passfile := filepath.Join(storeDir, path+passageExt) + + if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if !*force && !*forceShort && !*inPlace && !*inPlaceShort { + if _, err := os.Stat(passfile); err == nil { + fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + os.Exit(1) + } + } + } + + // Generate password + var charset string + if *noSymbols || *noSymbolsShort { + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + } else { + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?" + } + + password := generatePassword(length, charset) + + if *inPlace || *inPlaceShort { + // Read existing file and replace first line + if _, err := os.Stat(passfile); err != nil { + fmt.Fprintf(os.Stderr, "Error: file does not exist for in-place edit\n") + os.Exit(1) + } + + decrypted, err := decryptFile(passfile, "", masterPassword.Bytes()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err) + os.Exit(1) + } + + data, err := io.ReadAll(decrypted) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err) + os.Exit(1) + } + + lines := strings.Split(string(data), "\n") + if len(lines) > 0 { + lines[0] = password + } else { + lines = []string{password} + } + newContent := strings.Join(lines, "\n") + + if err := checkPasswordLength(newContent); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + tmpFile, err := createSecureTempFile("passage-generate-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer os.Remove(tmpFile) + + if err := os.WriteFile(tmpFile, []byte(newContent), 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + + if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + } else { + // Create new file + tmpFile, err := createSecureTempFile("passage-generate-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer os.Remove(tmpFile) + + if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + + if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + } + + verb := "Add" + if *inPlace || *inPlaceShort { + verb = "Replace" + } + gitAddFile(passfile, fmt.Sprintf("%s generated password for %s.", verb, path)) + + if *clip || *clipShort { + clipToClipboard(password, path) + } else { + fmt.Printf("The generated password for %s is:\n%s\n", path, password) + } +} + +// cmdDelete removes a password +func cmdDelete(args []string) { + fs := flag.NewFlagSet("rm", flag.ExitOnError) + recursive := fs.Bool("recursive", false, "recursive delete") + recursiveShort := fs.Bool("r", false, "recursive delete (short)") + force := fs.Bool("force", false, "delete without prompting") + forceShort := fs.Bool("f", false, "delete without prompting (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 rm [--recursive] [--force] pass-name\n") + os.Exit(1) + } + + path := fs.Arg(0) + if err := checkSneakyPaths(path); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + var err error + path, err = sanitizePath(path) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + storeDir := getStoreDir() + passfile := filepath.Join(storeDir, path+passageExt) + passdir := filepath.Join(storeDir, path) + + var target string + if info, err2 := os.Stat(passfile); err2 == nil && !info.IsDir() { + target = passfile + } else if info, err2 := os.Stat(passdir); err2 == nil && info.IsDir() { + target = passdir + } else { + fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path) + os.Exit(1) + } + + if !*force && !*forceShort { + fmt.Printf("Are you sure you would like to delete %s? [y/N] ", path) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + os.Exit(1) + } + } + + if *recursive || *recursiveShort { + err = os.RemoveAll(target) + } else { + err = os.Remove(target) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to delete: %v\n", err) + os.Exit(1) + } + + gitRemoveFile(target, fmt.Sprintf("Remove %s from store.", path)) + fmt.Printf("Removed %s\n", path) +} + +// cmdMove moves/renames a password +func cmdMove(args []string) { + fs := flag.NewFlagSet("mv", flag.ExitOnError) + force := fs.Bool("force", false, "overwrite without prompting") + forceShort := fs.Bool("f", false, "overwrite without prompting (short)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if fs.NArg() != 2 { + fmt.Fprintf(os.Stderr, "Usage: passage mv [--force] old-path new-path\n") + os.Exit(1) + } + + oldPath := fs.Arg(0) + newPath := fs.Arg(1) + if err := checkSneakyPaths(oldPath); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + if err := checkSneakyPaths(newPath); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + oldPath, err := sanitizePath(oldPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + newPath, err = sanitizePath(newPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + storeDir := getStoreDir() + oldFile := filepath.Join(storeDir, oldPath+passageExt) + newFile := filepath.Join(storeDir, newPath+passageExt) + + if _, err := os.Stat(oldFile); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath) + os.Exit(1) + } + + if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort { + fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + os.Exit(1) + } + } + + if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + // Get master password for re-encryption + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Decrypt old file + decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err) + os.Exit(1) + } + + data, err := io.ReadAll(decrypted) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err) + os.Exit(1) + } + + // Validate data length + if err := checkPasswordLength(string(data)); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + // Encrypt with new recipients + tmpFile, err := createSecureTempFile("passage-mv-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer os.Remove(tmpFile) + + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + + if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + + // Remove old file + if err := os.Remove(oldFile); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to remove old file: %v\n", err) + os.Exit(1) + } + + gitMoveFile(oldFile, newFile, fmt.Sprintf("Rename %s to %s.", oldPath, newPath)) + fmt.Printf("Renamed %s to %s\n", oldPath, newPath) +} + +// cmdCopy copies a password +func cmdCopy(args []string) { + fs := flag.NewFlagSet("cp", flag.ExitOnError) + force := fs.Bool("force", false, "overwrite without prompting") + forceShort := fs.Bool("f", false, "overwrite without prompting (short)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if fs.NArg() != 2 { + fmt.Fprintf(os.Stderr, "Usage: passage cp [--force] old-path new-path\n") + os.Exit(1) + } + + oldPath := fs.Arg(0) + newPath := fs.Arg(1) + if err := checkSneakyPaths(oldPath); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + if err := checkSneakyPaths(newPath); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + oldPath, err := sanitizePath(oldPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + newPath, err = sanitizePath(newPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + storeDir := getStoreDir() + oldFile := filepath.Join(storeDir, oldPath+passageExt) + newFile := filepath.Join(storeDir, newPath+passageExt) + + if _, err := os.Stat(oldFile); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath) + os.Exit(1) + } + + if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort { + fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + os.Exit(1) + } + } + + if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err) + os.Exit(1) + } + + // Get master password for re-encryption + masterPassword, err := getMasterPasswordForOperation(storeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Decrypt old file + decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err) + os.Exit(1) + } + + data, err := io.ReadAll(decrypted) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err) + os.Exit(1) + } + + // Validate data length + if err := checkPasswordLength(string(data)); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + // Encrypt with new recipients + tmpFile, err := createSecureTempFile("passage-mv-", ".tmp") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err) + os.Exit(1) + } + defer os.Remove(tmpFile) + + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err) + os.Exit(1) + } + + if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err) + os.Exit(1) + } + + gitAddFile(newFile, fmt.Sprintf("Copy %s to %s.", oldPath, newPath)) + fmt.Printf("Copied %s to %s\n", oldPath, newPath) +} + +// cmdGit runs git commands +func cmdGit(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: passage git git-command-args...\n") + os.Exit(1) + } + + storeDir := getStoreDir() + gitDir := filepath.Join(storeDir, ".git") + + if args[0] == "init" { + cmd := exec.Command("git", append([]string{"-C", storeDir, "init"}, args[1:]...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: git init failed: %v\n", err) + os.Exit(1) + } + return + } + + if _, err := os.Stat(gitDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: the password store is not a git repository. Try \"passage git init\"\n") + os.Exit(1) + } + + cmd := exec.Command("git", append([]string{"-C", storeDir}, args...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: git command failed: %v\n", err) + os.Exit(1) + } +} + +// Helper functions + +func readPassword() ([]byte, error) { + if term.IsTerminal(int(os.Stdin.Fd())) { + return term.ReadPassword(int(os.Stdin.Fd())) + } + // Fallback for non-terminal input + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + return []byte(strings.TrimSpace(line)), nil +} + +func generatePassword(length int, charset string) string { + password := make([]byte, length) + max := big.NewInt(int64(len(charset))) + for i := range password { + n, err := rand.Int(rand.Reader, max) + if err != nil { + // crypto/rand failed? That's... concerning. Fallback to deterministic (not ideal) + password[i] = charset[i%len(charset)] + } else { + password[i] = charset[n.Int64()] + } + } + return string(password) +} + +// clipToClipboard is now in clipboard.go + +func gitAddFile(path, message string) { + storeDir := getStoreDir() + gitDir := filepath.Join(storeDir, ".git") + if _, err := os.Stat(gitDir); err != nil { + return // Not a git repo + } + + relPath, err := filepath.Rel(storeDir, path) + if err != nil { + return + } + + // Clean up the commit message - don't let users inject commands + message = sanitizeGitMessage(message) + + cmd := exec.Command("git", "-C", storeDir, "add", relPath) + if err := cmd.Run(); err != nil { + // Git failed? Oh well, not critical - passwords are still saved + return + } + + cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message) + cmd.Run() // Git is optional, so failures are fine +} + +func gitRemoveFile(path, message string) { + storeDir := getStoreDir() + gitDir := filepath.Join(storeDir, ".git") + if _, err := os.Stat(gitDir); err != nil { + return + } + + relPath, err := filepath.Rel(storeDir, path) + if err != nil { + return + } + + // Sanitize commit message + message = sanitizeGitMessage(message) + + cmd := exec.Command("git", "-C", storeDir, "rm", "-r", relPath) + if err := cmd.Run(); err != nil { + return + } + + cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message) + cmd.Run() +} + +func gitMoveFile(oldPath, newPath, message string) { + storeDir := getStoreDir() + gitDir := filepath.Join(storeDir, ".git") + if _, err := os.Stat(gitDir); err != nil { + return + } + + oldRel, err := filepath.Rel(storeDir, oldPath) + if err != nil { + return + } + newRel, err := filepath.Rel(storeDir, newPath) + if err != nil { + return + } + + // Sanitize commit message + message = sanitizeGitMessage(message) + + cmd := exec.Command("git", "-C", storeDir, "mv", oldRel, newRel) + if err := cmd.Run(); err != nil { + return + } + + cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message) + cmd.Run() +} + +// sanitizeGitMessage removes potentially dangerous characters from git commit messages +// Because git commit messages shouldn't be attack vectors +func sanitizeGitMessage(message string) string { + // Strip out newlines and null bytes - they could be used for injection + message = strings.ReplaceAll(message, "\n", " ") + message = strings.ReplaceAll(message, "\r", " ") + message = strings.ReplaceAll(message, "\x00", "") + // Reasonable length limit - if you need more, write a blog post instead + const maxMessageLength = 1000 + if len(message) > maxMessageLength { + message = message[:maxMessageLength] + } + return message +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3afebf9 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..893988d --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..83133ed --- /dev/null +++ b/main.go @@ -0,0 +1,97 @@ +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:]) + } +} diff --git a/memory.go b/memory.go new file mode 100644 index 0000000..75762b7 --- /dev/null +++ b/memory.go @@ -0,0 +1,68 @@ +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 = "" +} diff --git a/passage.1.md b/passage.1.md new file mode 100644 index 0000000..a8fd696 --- /dev/null +++ b/passage.1.md @@ -0,0 +1,265 @@ +% 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 https://git.fraggle.lol/fraggle/PassAGE/issues + +# AUTHOR + +PassAGE developers + +# COPYRIGHT + +This project uses AGE encryption. See LICENSE file for details. diff --git a/security.go b/security.go new file mode 100644 index 0000000..b0773d8 --- /dev/null +++ b/security.go @@ -0,0 +1,163 @@ +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 +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..3d436ac --- /dev/null +++ b/store.go @@ -0,0 +1,346 @@ +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) +// TODO: Maybe remove this eventually? But backwards compat is nice... +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 +}