From 3552db50c2bd73e57144b97ee49eb6f159dd6018 Mon Sep 17 00:00:00 2001 From: fraggle Date: Sun, 11 Jan 2026 18:48:01 -0400 Subject: [PATCH] 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 --- .github/workflows/build.yml | 45 ++ .gitignore | 42 ++ CHANGELOG.md | 19 + CODE_STRUCTURE.md | 112 ++++ CONTRIBUTING.md | 76 +++ INSTALL.md | 175 +++++ Makefile | 63 ++ README.md | 228 +++++++ SECURITY.md | 42 ++ SETUP.md | 123 ++++ backup.go | 306 +++++++++ clipboard.go | 78 +++ commands.go | 1213 +++++++++++++++++++++++++++++++++++ go.mod | 11 + go.sum | 8 + main.go | 97 +++ memory.go | 68 ++ passage.1.md | 265 ++++++++ security.go | 163 +++++ store.go | 346 ++++++++++ 20 files changed, 3480 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_STRUCTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 INSTALL.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 SETUP.md create mode 100644 backup.go create mode 100644 clipboard.go create mode 100644 commands.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 memory.go create mode 100644 passage.1.md create mode 100644 security.go create mode 100644 store.go 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 +}