Compare commits
No commits in common. "81f0030069e2cbaa6012919284acf60e255503a7" and "32db394b1eb6877ee8dc3a7e6760345a6816be97" have entirely different histories.
81f0030069
...
32db394b1e
42
.gitignore
vendored
42
.gitignore
vendored
@ -1,42 +0,0 @@
|
||||
# Binaries
|
||||
passage
|
||||
passage.exe
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
passage.1
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.log
|
||||
|
||||
# User-specific files
|
||||
*.local
|
||||
@ -1,73 +0,0 @@
|
||||
# Contributing to PassAGE
|
||||
|
||||
Thank you for your interest in contributing to PassAGE!
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the repository** (if using a forge like Gitea)
|
||||
2. **Clone the repository** locally
|
||||
3. **Create a branch** for your changes
|
||||
4. **Make your changes** and test them
|
||||
5. **Submit a merge request** or push your changes
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. **Fork and clone the repository:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd PassAGE
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
3. **Build the project:**
|
||||
```bash
|
||||
make build
|
||||
# or
|
||||
go build -o passage .
|
||||
```
|
||||
|
||||
4. **Run tests:**
|
||||
```bash
|
||||
make test
|
||||
# or
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard Go formatting (`go fmt`)
|
||||
- Run `go vet` before committing
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for non-obvious code
|
||||
|
||||
## Testing
|
||||
|
||||
- Write tests for new features
|
||||
- Ensure all tests pass: `go test ./...`
|
||||
- Test with race detector: `make test-race`
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Create a branch for your changes
|
||||
2. Make your changes
|
||||
3. Ensure code compiles and tests pass
|
||||
4. Submit a pull request with a clear description
|
||||
|
||||
## Building Manpages
|
||||
|
||||
If you modify documentation, rebuild the manpage:
|
||||
|
||||
```bash
|
||||
make man
|
||||
```
|
||||
|
||||
Requires `pandoc` or `go-md2man` to be installed.
|
||||
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open an issue or start a discussion in the repository.
|
||||
173
INSTALL.md
173
INSTALL.md
@ -1,173 +0,0 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation instructions for PassAGE.
|
||||
|
||||
**Quick Install:** `go install <repository-url>@latest`
|
||||
|
||||
## Quick Install
|
||||
|
||||
### Using Go (Recommended)
|
||||
|
||||
```bash
|
||||
go install <repository-url>@latest
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
# Clone repository and build
|
||||
git clone <repository-url>
|
||||
cd PassAGE
|
||||
make install
|
||||
|
||||
# Or for user installation (no sudo)
|
||||
make install-user
|
||||
```
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Go 1.21 or later** - Required for building from source
|
||||
- **Linux/Unix system** - Designed for Unix-like operating systems
|
||||
- **xclip or wl-clipboard** - For clipboard support (X11/Wayland)
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
### Required
|
||||
- Go toolchain (1.21+)
|
||||
|
||||
### Optional
|
||||
- **pandoc** - For building manpages
|
||||
- Debian/Ubuntu: `sudo apt-get install pandoc`
|
||||
- Arch: `sudo pacman -S pandoc`
|
||||
- Or use `go-md2man`: `go install github.com/cpuguy83/go-md2man/v2@latest`
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd PassAGE
|
||||
```
|
||||
|
||||
### 2. Download Dependencies
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
This downloads all required Go modules.
|
||||
|
||||
### 3. Build
|
||||
|
||||
```bash
|
||||
# Simple build
|
||||
go build -o passage .
|
||||
|
||||
# Or use Makefile
|
||||
make build
|
||||
|
||||
# Release build (optimized, smaller)
|
||||
make build-release
|
||||
```
|
||||
|
||||
### 4. Install
|
||||
|
||||
**System-wide (requires sudo):**
|
||||
```bash
|
||||
sudo make install
|
||||
```
|
||||
|
||||
**User installation (no sudo):**
|
||||
```bash
|
||||
make install-user
|
||||
```
|
||||
|
||||
### 5. Install Manpage (Optional)
|
||||
|
||||
**System-wide:**
|
||||
```bash
|
||||
make man
|
||||
sudo make install-man
|
||||
```
|
||||
|
||||
**User installation:**
|
||||
```bash
|
||||
make man
|
||||
make install-user-man
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
# Check version
|
||||
passage version
|
||||
|
||||
# View help
|
||||
passage help
|
||||
|
||||
# View manpage (if installed)
|
||||
man passage
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails
|
||||
|
||||
1. **Check Go version:**
|
||||
```bash
|
||||
go version # Should be 1.21 or later
|
||||
```
|
||||
|
||||
2. **Verify dependencies:**
|
||||
```bash
|
||||
go mod verify
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. **Clean and rebuild:**
|
||||
```bash
|
||||
make clean
|
||||
make build
|
||||
```
|
||||
|
||||
### Manpage Won't Build
|
||||
|
||||
Install `pandoc`:
|
||||
- Debian/Ubuntu: `sudo apt-get install pandoc`
|
||||
- macOS: `brew install pandoc`
|
||||
|
||||
Or use `go-md2man`:
|
||||
```bash
|
||||
go install github.com/cpuguy83/go-md2man/v2@latest
|
||||
```
|
||||
|
||||
### Clipboard Doesn't Work
|
||||
|
||||
Ensure you have clipboard tools installed:
|
||||
- X11: `xclip` or `xsel`
|
||||
- Wayland: `wl-clipboard`
|
||||
|
||||
Install:
|
||||
- Debian/Ubuntu: `sudo apt-get install xclip` or `sudo apt-get install wl-clipboard`
|
||||
- Arch: `sudo pacman -S xclip` or `sudo pacman -S wl-clipboard`
|
||||
- Other distributions: Install `xclip` or `wl-clipboard` from your package manager
|
||||
|
||||
## Uninstallation
|
||||
|
||||
```bash
|
||||
# Remove binary
|
||||
sudo rm /usr/local/bin/passage
|
||||
# or for user installation
|
||||
rm ~/.local/bin/passage
|
||||
|
||||
# Remove manpage
|
||||
sudo rm /usr/local/share/man/man1/passage.1
|
||||
# or for user installation
|
||||
rm ~/.local/share/man/man1/passage.1
|
||||
|
||||
# Update man database
|
||||
sudo mandb
|
||||
# or
|
||||
mandb ~/.local/share/man
|
||||
```
|
||||
63
Makefile
63
Makefile
@ -1,63 +0,0 @@
|
||||
.PHONY: build install clean test release man install-man
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
PREFIX ?= /usr/local
|
||||
MANPREFIX ?= $(PREFIX)/share/man
|
||||
|
||||
build:
|
||||
go build -o passage .
|
||||
|
||||
build-release:
|
||||
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o passage .
|
||||
|
||||
install: build
|
||||
@mkdir -p $(DESTDIR)$(PREFIX)/bin
|
||||
cp passage $(DESTDIR)$(PREFIX)/bin/passage
|
||||
@echo "Installed passage to $(DESTDIR)$(PREFIX)/bin/passage"
|
||||
|
||||
install-user: build
|
||||
@mkdir -p ~/.local/bin
|
||||
cp passage ~/.local/bin/passage
|
||||
@echo "Installed passage to ~/.local/bin/passage"
|
||||
|
||||
man: passage.1
|
||||
|
||||
passage.1: passage.1.md
|
||||
@if command -v pandoc >/dev/null 2>&1; then \
|
||||
pandoc passage.1.md -s -t man -o passage.1; \
|
||||
elif command -v go-md2man >/dev/null 2>&1; then \
|
||||
go-md2man -in passage.1.md -out passage.1; \
|
||||
else \
|
||||
echo "Error: pandoc or go-md2man required to build manpage"; \
|
||||
echo "Install pandoc: sudo apt-get install pandoc (Debian/Ubuntu)"; \
|
||||
echo "Or install go-md2man: go install github.com/cpuguy83/go-md2man/v2@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
install-man: man
|
||||
@mkdir -p $(DESTDIR)$(MANPREFIX)/man1
|
||||
cp passage.1 $(DESTDIR)$(MANPREFIX)/man1/passage.1
|
||||
@if command -v mandb >/dev/null 2>&1; then \
|
||||
mandb $(DESTDIR)$(MANPREFIX) >/dev/null 2>&1 || true; \
|
||||
fi
|
||||
@echo "Installed manpage to $(DESTDIR)$(MANPREFIX)/man1/passage.1"
|
||||
|
||||
install-user-man: man
|
||||
@mkdir -p ~/.local/share/man/man1
|
||||
cp passage.1 ~/.local/share/man/man1/passage.1
|
||||
@if command -v mandb >/dev/null 2>&1; then \
|
||||
mandb ~/.local/share/man >/dev/null 2>&1 || true; \
|
||||
fi
|
||||
@echo "Installed manpage to ~/.local/share/man/man1/passage.1"
|
||||
|
||||
clean:
|
||||
rm -f passage passage.1
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
test-race:
|
||||
go test -race ./...
|
||||
|
||||
release: build-release
|
||||
@echo "Built release version: $(VERSION)"
|
||||
220
README.md
220
README.md
@ -1,220 +1,4 @@
|
||||
# PassAGE
|
||||
|
||||
A modern password manager using AGE encryption.
|
||||
|
||||
**Quick Links:** [Installation](#installation) • [Quick Start](#quick-start) • [Commands](#usage) • [Security](#security) • [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## Features
|
||||
|
||||
- **AGE encryption**: Uses AGE (Actually Good Encryption) for secure password storage
|
||||
- **Master password model**: Single password protects all stored passwords
|
||||
- **Git integration**: Optional git repository support for version control
|
||||
- **Command-line interface**: Simple, intuitive commands
|
||||
- **Linux/Unix**: Designed for Linux and Unix-like systems
|
||||
- **Clipboard support**: X11 and Wayland clipboard integration
|
||||
- **Secure password generation**: Cryptographically secure random password generation
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.21 or later** - Required for building
|
||||
- **Linux/Unix system** - Designed for Unix-like operating systems
|
||||
- **pandoc** or **go-md2man** (optional) - For building manpages
|
||||
- **xclip** or **wl-clipboard** - For clipboard support (X11/Wayland)
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Download dependencies
|
||||
go mod download
|
||||
|
||||
# Build
|
||||
go build -o passage .
|
||||
|
||||
# Or use Makefile
|
||||
make build
|
||||
```
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
# System-wide installation
|
||||
sudo make install
|
||||
|
||||
# User installation (no sudo required)
|
||||
make install-user
|
||||
```
|
||||
|
||||
**Or install directly with go:**
|
||||
|
||||
```bash
|
||||
go install <repository-url>@latest
|
||||
```
|
||||
|
||||
#### Build Options
|
||||
|
||||
For production builds, you may want to use additional flags:
|
||||
|
||||
```bash
|
||||
# Build with version information
|
||||
go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o passage
|
||||
|
||||
# Build with trimmed paths (for reproducible builds)
|
||||
go build -trimpath -o passage
|
||||
|
||||
# Build with race detector (for testing)
|
||||
go build -race -o passage
|
||||
|
||||
# Build optimized binary (smaller, faster)
|
||||
go build -ldflags "-s -w" -trimpath -o passage
|
||||
```
|
||||
|
||||
**Build flags explained:**
|
||||
- `-ldflags "-X main.version=..."` - Inject version information at build time
|
||||
- `-trimpath` - Remove file system paths for reproducible builds
|
||||
- `-race` - Enable race detector (for debugging concurrency issues)
|
||||
- `-ldflags "-s -w"` - Strip debug symbols and disable DWARF generation (smaller binary)
|
||||
|
||||
### Install Binary
|
||||
|
||||
Pre-built binaries may be available from the releases page.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Initialize the password store
|
||||
|
||||
Initialize the password store with a master password:
|
||||
|
||||
```bash
|
||||
passage init
|
||||
```
|
||||
|
||||
This will prompt you to:
|
||||
- Enter a master password (used to encrypt/decrypt all passwords)
|
||||
- Confirm the master password
|
||||
|
||||
The master password is required for all operations.
|
||||
|
||||
### 2. Add a password
|
||||
|
||||
```bash
|
||||
passage insert example.com
|
||||
```
|
||||
|
||||
### 3. Retrieve a password
|
||||
|
||||
```bash
|
||||
passage show example.com
|
||||
```
|
||||
|
||||
### 4. Generate a password
|
||||
|
||||
```bash
|
||||
passage generate example.com 32
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Commands
|
||||
|
||||
- `passage init [--path=subfolder]` - Initialize password store with master password
|
||||
- `passage [ls] [subfolder]` - List passwords
|
||||
- `passage find pass-names...` - Find passwords by name
|
||||
- `passage [show] [--clip[=line-number]] pass-name` - Show password
|
||||
- `passage grep search-string` - Search within passwords
|
||||
- `passage insert [--multiline] [--force] pass-name` - Insert password
|
||||
- `passage edit pass-name` - Edit password
|
||||
- `passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]` - Generate password
|
||||
- `passage rm [--recursive] [--force] pass-name` - Remove password
|
||||
- `passage mv [--force] old-path new-path` - Move/rename password
|
||||
- `passage cp [--force] old-path new-path` - Copy password
|
||||
- `passage git git-command-args...` - Run git commands
|
||||
- `passage help` - Show help
|
||||
- `passage version` - Show version
|
||||
|
||||
### Environment Variables
|
||||
|
||||
passage respects the following environment variables:
|
||||
|
||||
- **PASSAGE_DIR** - Path to password store (default: `~/.passage-store`)
|
||||
```bash
|
||||
export PASSAGE_DIR=~/my-passwords
|
||||
```
|
||||
|
||||
- **PASSAGE_CLIP_TIME** - Time in seconds to keep password in clipboard before auto-clearing (default: 10)
|
||||
```bash
|
||||
export PASSAGE_CLIP_TIME=30 # Keep in clipboard for 30 seconds
|
||||
```
|
||||
|
||||
- **PASSAGE_GENERATED_LENGTH** - Default length for generated passwords (default: 25)
|
||||
```bash
|
||||
export PASSAGE_GENERATED_LENGTH=32 # Generate 32-character passwords by default
|
||||
```
|
||||
|
||||
- **EDITOR** - Editor to use for `passage edit` command (default: `vi`)
|
||||
```bash
|
||||
export EDITOR=nano # Use nano instead of vi
|
||||
```
|
||||
|
||||
**Note:** For complete documentation of all environment variables, see the [manpage](#manpage) or run `man passage` after installation.
|
||||
|
||||
## Git Integration
|
||||
|
||||
Initialize git repository:
|
||||
|
||||
```bash
|
||||
passage git init
|
||||
```
|
||||
|
||||
All password operations automatically commit to git (if initialized).
|
||||
|
||||
## Security
|
||||
|
||||
**IMPORTANT**: PassAGE uses a master password model. Every operation requires the master password set during `passage init`.
|
||||
|
||||
### Quick Security Overview
|
||||
|
||||
- **Master password**: Single password protects all stored passwords (never stored in plaintext)
|
||||
- **AGE Scrypt encryption**: Industry-standard passphrase encryption
|
||||
- **Argon2id verification**: Master password verified using Argon2id hash (memory-hard, resistant to brute force)
|
||||
- **File permissions**: Store directory uses 0700, sensitive files use 0600
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Choose a strong master password (it cannot be recovered if forgotten)
|
||||
2. Use full disk encryption
|
||||
3. Back up your password store directory
|
||||
4. See [SECURITY.md](SECURITY.md) for detailed security information
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions and troubleshooting
|
||||
- **[SECURITY.md](SECURITY.md)** - Security implementation details
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - How to contribute to the project
|
||||
- **Manpage** - Run `man passage` after installation (source: `passage.1.md`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
PassAGE/
|
||||
├── main.go # Entry point, command routing
|
||||
├── commands.go # Command implementations
|
||||
├── store.go # Core store operations
|
||||
├── security.go # Security utilities (path validation, etc.)
|
||||
├── memory.go # Secure memory management
|
||||
├── clipboard.go # Clipboard operations (Linux X11/Wayland)
|
||||
├── backup.go # Backup/restore functionality
|
||||
├── go.mod # Go module definition
|
||||
├── Makefile # Build and install targets
|
||||
├── passage.1.md # Manpage source
|
||||
└── *.md # Documentation files
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project uses AGE encryption. See LICENSE file for details.
|
||||
PassAGE is a command-line password manager that uses AGE (Actually Good Encryption) for secure password
|
||||
storage.
|
||||
42
SECURITY.md
42
SECURITY.md
@ -1,42 +0,0 @@
|
||||
# Security Considerations for PassAGE
|
||||
|
||||
This document describes PassAGE's security model and implementation details.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
PassAGE uses a master password model with AGE's passphrase encryption (Scrypt).
|
||||
|
||||
### Master Password Storage
|
||||
|
||||
The master password is **never stored in plaintext**. Instead:
|
||||
|
||||
1. **Argon2id hash**: A one-way hash is stored in `.master-pass` file in the password store directory
|
||||
- Uses Argon2id (winner of Password Hashing Competition)
|
||||
- Parameters: 3 iterations, 32MB memory, 4 threads, 32-byte output
|
||||
- Includes random salt (16 bytes) for each password
|
||||
- Format: `salt:hash` (base64 encoded)
|
||||
|
||||
2. **Verification**: When you enter the master password:
|
||||
- A new hash is computed with the same salt
|
||||
- Compared with stored hash using constant-time comparison
|
||||
- If match, password is used for encryption/decryption
|
||||
|
||||
3. **Security properties**:
|
||||
- **Memory-hard**: Resistant to GPU/ASIC attacks
|
||||
- **Slow by design**: Makes brute force attacks expensive
|
||||
- **Salt**: Prevents rainbow table attacks
|
||||
- **One-way**: Hash cannot be reversed to get password
|
||||
|
||||
### Encryption
|
||||
|
||||
All passwords are encrypted using:
|
||||
- **AGE Scrypt encryption**: Industry-standard passphrase encryption
|
||||
- **Master password**: Used directly for encryption (not stored)
|
||||
- **File format**: AGE v1 encrypted files (`.passage` extension)
|
||||
|
||||
### Security Model
|
||||
|
||||
- Master password protects all stored passwords
|
||||
- Hash file is only for verification (cannot recover password)
|
||||
- Full disk encryption recommended for additional protection
|
||||
- File permissions: `.master-pass` stored with 0600 permissions
|
||||
306
backup.go
306
backup.go
@ -1,306 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cmdBackup creates a backup of the password store
|
||||
func cmdBackup(args []string) {
|
||||
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||
outputFlag := fs.String("output", "", "output backup file path (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)")
|
||||
outputShort := fs.String("o", "", "output backup file path (short)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
storeDir := getStoreDir()
|
||||
if !checkStoreInitialized(storeDir) {
|
||||
fmt.Fprintf(os.Stderr, "Error: password store not initialized. Run 'passage init' first\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Determine output file
|
||||
outputFile := *outputFlag
|
||||
if outputFile == "" {
|
||||
outputFile = *outputShort
|
||||
}
|
||||
if outputFile == "" {
|
||||
// Generate default filename with timestamp
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
outputFile = fmt.Sprintf("passage-backup-%s.tar.gz", timestamp)
|
||||
}
|
||||
|
||||
// Validate output path
|
||||
if err := checkSneakyPaths(outputFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid output path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create backup file
|
||||
backupFile, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create backup file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer backupFile.Close()
|
||||
|
||||
// Create gzip writer
|
||||
gzipWriter := gzip.NewWriter(backupFile)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
// Create tar writer
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
// Calculate checksum while writing
|
||||
hash := sha256.New()
|
||||
multiWriter := io.MultiWriter(tarWriter, hash)
|
||||
|
||||
// Walk the store directory and add files to tar
|
||||
fileCount := 0
|
||||
err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip .git - it's usually huge and users can backup it separately if they want
|
||||
if info.IsDir() && strings.Contains(path, ".git") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Skip directories themselves
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate relative path
|
||||
relPath, err := filepath.Rel(storeDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create tar header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
header.Mode = int64(info.Mode())
|
||||
|
||||
// Write header
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open file and copy contents
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Copy file contents through multi-writer (to tar and hash)
|
||||
if _, err := io.Copy(multiWriter, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Finalize tar and gzip
|
||||
tarWriter.Close()
|
||||
gzipWriter.Close()
|
||||
backupFile.Close()
|
||||
|
||||
// Calculate final checksum
|
||||
checksum := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// Write checksum file for verification later
|
||||
checksumFile := outputFile + ".sha256"
|
||||
if err := os.WriteFile(checksumFile, []byte(checksum+" "+filepath.Base(outputFile)+"\n"), 0644); err != nil {
|
||||
// Checksum file failed? Not critical, but warn about it
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to write checksum file: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Backup created: %s\n", outputFile)
|
||||
fmt.Printf("Files backed up: %d\n", fileCount)
|
||||
fmt.Printf("Checksum: %s\n", checksum)
|
||||
fmt.Printf("Checksum file: %s\n", checksumFile)
|
||||
}
|
||||
|
||||
// cmdRestore restores a backup of the password store
|
||||
func cmdRestore(args []string) {
|
||||
fs := flag.NewFlagSet("restore", flag.ExitOnError)
|
||||
force := fs.Bool("force", false, "overwrite existing store without prompting")
|
||||
forceShort := fs.Bool("f", false, "overwrite existing store without prompting (short)")
|
||||
skipVerify := fs.Bool("skip-verify", false, "skip checksum verification")
|
||||
skipVerifyShort := fs.Bool("s", false, "skip checksum verification (short)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if fs.NArg() != 1 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: passage restore [--force] [--skip-verify] backup-file.tar.gz\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
backupFile := fs.Arg(0)
|
||||
|
||||
// Validate backup file path
|
||||
if err := checkSneakyPaths(backupFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid backup file path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if backup file exists
|
||||
if _, err := os.Stat(backupFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: backup file not found: %s\n", backupFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Verify checksum if not skipped (you really shouldn't skip this)
|
||||
if !*skipVerify && !*skipVerifyShort {
|
||||
checksumFile := backupFile + ".sha256"
|
||||
if _, err := os.Stat(checksumFile); err == nil {
|
||||
expectedChecksumData, err := os.ReadFile(checksumFile)
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(expectedChecksumData)), " ")
|
||||
if len(lines) >= 1 {
|
||||
expectedChecksum := strings.TrimSpace(lines[0])
|
||||
if !verifyBackupChecksum(backupFile, expectedChecksum) {
|
||||
fmt.Fprintf(os.Stderr, "Error: backup file checksum verification failed!\n")
|
||||
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore anyway (not recommended)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Checksum verified: OK\n")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No checksum file? That's suspicious - require explicit skip
|
||||
fmt.Fprintf(os.Stderr, "Warning: checksum file not found: %s\n", checksumFile)
|
||||
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore without verification\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
storeDir := getStoreDir()
|
||||
|
||||
// Check if store already exists
|
||||
if checkStoreInitialized(storeDir) {
|
||||
if !*force && !*forceShort {
|
||||
fmt.Printf("Warning: password store already exists at %s\n", storeDir)
|
||||
fmt.Printf("Restoring will overwrite existing files. Continue? [y/N] ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open backup file
|
||||
file, err := os.Open(backupFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open backup file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create gzip reader
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to read gzip archive: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
// Create tar reader
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
|
||||
// Extract files one by one
|
||||
fileCount := 0
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break // All done
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to read tar archive: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Paranoid path check - even in backups, we don't trust paths
|
||||
if err := checkSneakyPaths(header.Name); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid path in backup: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Construct full path
|
||||
targetPath := filepath.Join(storeDir, header.Name)
|
||||
|
||||
// Create parent directories
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create file
|
||||
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Copy file contents
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to write file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
outFile.Close()
|
||||
fileCount++
|
||||
}
|
||||
|
||||
fmt.Printf("Restore completed: %d files restored\n", fileCount)
|
||||
fmt.Printf("Store location: %s\n", storeDir)
|
||||
}
|
||||
|
||||
// verifyBackupChecksum verifies the SHA256 checksum of a backup file
|
||||
func verifyBackupChecksum(backupFile, expectedChecksum string) bool {
|
||||
file, err := os.Open(backupFile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
actualChecksum := hex.EncodeToString(hash.Sum(nil))
|
||||
return constantTimeStringCompare(actualChecksum, expectedChecksum)
|
||||
}
|
||||
78
clipboard.go
78
clipboard.go
@ -1,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clearClipboardAfter clears the clipboard after a delay
|
||||
// Runs in a goroutine so it doesn't block
|
||||
func clearClipboardAfter(duration time.Duration) {
|
||||
go func() {
|
||||
time.Sleep(duration)
|
||||
clearClipboard()
|
||||
}()
|
||||
}
|
||||
|
||||
// clearClipboard clears the clipboard
|
||||
// Tries both Wayland and X11 - whichever is available
|
||||
func clearClipboard() {
|
||||
var cmd *exec.Cmd
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
cmd = exec.Command("wl-copy", "--clear")
|
||||
} else if os.Getenv("DISPLAY") != "" {
|
||||
// X11 - clear by copying /dev/null (hacky but works)
|
||||
cmd = exec.Command("xclip", "-selection", "clipboard", "/dev/null")
|
||||
} else {
|
||||
return // No clipboard available, nothing to clear
|
||||
}
|
||||
cmd.Run() // Best effort - if it fails, oh well
|
||||
}
|
||||
|
||||
// clipToClipboard copies text to clipboard and sets up auto-clear
|
||||
func clipToClipboard(text, path string) {
|
||||
clipTime := 10
|
||||
if env := os.Getenv("PASSAGE_CLIP_TIME"); env != "" {
|
||||
if n, err := fmt.Sscanf(env, "%d", &clipTime); err != nil || n != 1 || clipTime < 0 || clipTime > 3600 {
|
||||
clipTime = 10 // Reset to default on invalid input
|
||||
}
|
||||
}
|
||||
|
||||
// Try xclip (X11) or wl-copy (Wayland)
|
||||
var cmd *exec.Cmd
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
cmd = exec.Command("wl-copy")
|
||||
} else if os.Getenv("DISPLAY") != "" {
|
||||
cmd = exec.Command("xclip", "-selection", "clipboard")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no clipboard available (needs X11 or Wayland)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to copy to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Copied %s to clipboard. Will clear in %d seconds.\n", path, clipTime)
|
||||
|
||||
// Set up signal handler to clear clipboard if user hits Ctrl+C
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Auto-clear after the timeout
|
||||
clearClipboardAfter(time.Duration(clipTime) * time.Second)
|
||||
|
||||
// Also clear if interrupted - don't leave passwords lying around
|
||||
go func() {
|
||||
<-sigChan
|
||||
clearClipboard()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
1213
commands.go
1213
commands.go
File diff suppressed because it is too large
Load Diff
11
go.mod
11
go.mod
@ -1,11 +0,0 @@
|
||||
module passage
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
filippo.io/age v1.1.1
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/term v0.19.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.19.0 // indirect
|
||||
8
go.sum
8
go.sum
@ -1,8 +0,0 @@
|
||||
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
|
||||
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
97
main.go
97
main.go
@ -1,97 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
const usage = `PassAGE - A modern password manager using AGE encryption
|
||||
|
||||
Usage:
|
||||
passage init [--path=subfolder]
|
||||
Initialize new password storage with a master password.
|
||||
Prompts for master password (used to encrypt/decrypt all passwords).
|
||||
passage [ls] [subfolder]
|
||||
List passwords.
|
||||
passage find pass-names...
|
||||
List passwords that match pass-names.
|
||||
passage [show] [--clip[=line-number]] pass-name
|
||||
Show existing password and optionally put it on the clipboard.
|
||||
passage grep [GREPOPTIONS] search-string
|
||||
Search for password files containing search-string when decrypted.
|
||||
passage insert [--multiline] [--force] pass-name
|
||||
Insert new password. Optionally, the entry may be multiline.
|
||||
passage edit pass-name
|
||||
Insert a new password or edit an existing password using ${EDITOR:-vi}.
|
||||
passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]
|
||||
Generate a new password of pass-length (or 25 if unspecified).
|
||||
passage rm [--recursive] [--force] pass-name
|
||||
Remove existing password or directory, optionally forcefully.
|
||||
passage mv [--force] old-path new-path
|
||||
Renames or moves old-path to new-path, optionally forcefully.
|
||||
passage cp [--force] old-path new-path
|
||||
Copies old-path to new-path, optionally forcefully.
|
||||
passage backup [--output=file.tar.gz]
|
||||
Create a backup of the password store with integrity verification.
|
||||
passage restore [--force] [--skip-verify] backup-file.tar.gz
|
||||
Restore a backup of the password store with integrity verification.
|
||||
passage git git-command-args...
|
||||
If the password store is a git repository, execute a git command.
|
||||
passage help
|
||||
Show this text.
|
||||
passage version
|
||||
Show version information.
|
||||
|
||||
Environment variables:
|
||||
PASSAGE_DIR Path to the password store (default: ~/.passage-store)
|
||||
PASSAGE_CLIP_TIME Time in seconds to keep password in clipboard (default: 10)
|
||||
PASSAGE_GENERATED_LENGTH Default length for generated passwords (default: 25)
|
||||
`
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
cmdShow([]string{})
|
||||
return
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch command {
|
||||
case "init":
|
||||
cmdInit(args)
|
||||
case "help", "--help", "-h":
|
||||
fmt.Print(usage)
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("PassAGE version %s\n", version)
|
||||
case "show", "ls", "list":
|
||||
cmdShow(args)
|
||||
case "find", "search":
|
||||
cmdFind(args)
|
||||
case "grep":
|
||||
cmdGrep(args)
|
||||
case "insert", "add":
|
||||
cmdInsert(args)
|
||||
case "edit":
|
||||
cmdEdit(args)
|
||||
case "generate":
|
||||
cmdGenerate(args)
|
||||
case "delete", "rm", "remove":
|
||||
cmdDelete(args)
|
||||
case "rename", "mv":
|
||||
cmdMove(args)
|
||||
case "copy", "cp":
|
||||
cmdCopy(args)
|
||||
case "backup":
|
||||
cmdBackup(args)
|
||||
case "restore":
|
||||
cmdRestore(args)
|
||||
case "git":
|
||||
cmdGit(args)
|
||||
default:
|
||||
// Try as show command
|
||||
cmdShow(os.Args[1:])
|
||||
}
|
||||
}
|
||||
68
memory.go
68
memory.go
@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// SecureBytes wraps a byte slice and provides secure clearing
|
||||
// Because Go strings are immutable and we can't really clear them, this helps
|
||||
type SecureBytes struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewSecureBytes creates a new SecureBytes from a byte slice
|
||||
func NewSecureBytes(data []byte) *SecureBytes {
|
||||
return &SecureBytes{data: data}
|
||||
}
|
||||
|
||||
// Bytes returns the underlying byte slice
|
||||
func (sb *SecureBytes) Bytes() []byte {
|
||||
return sb.data
|
||||
}
|
||||
|
||||
// String returns the string representation (use with caution)
|
||||
// This uses unsafe.Pointer magic - don't look too closely, it works
|
||||
func (sb *SecureBytes) String() string {
|
||||
return *(*string)(unsafe.Pointer(&sb.data))
|
||||
}
|
||||
|
||||
// Clear securely zeros the memory
|
||||
// Overwrites with random junk first (defense in depth), then zeros
|
||||
// Not perfect (Go GC might move things around), but better than nothing
|
||||
func (sb *SecureBytes) Clear() {
|
||||
if sb == nil || sb.data == nil {
|
||||
return
|
||||
}
|
||||
// Fill with random data first - makes memory dumps less useful
|
||||
rand.Read(sb.data)
|
||||
// Then zero it out
|
||||
for i := range sb.data {
|
||||
sb.data[i] = 0
|
||||
}
|
||||
sb.data = nil
|
||||
}
|
||||
|
||||
// ClearBytes securely clears a byte slice
|
||||
func ClearBytes(data []byte) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
// Overwrite with random data first, then zeros
|
||||
rand.Read(data)
|
||||
for i := range data {
|
||||
data[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ClearString attempts to clear a string (limited effectiveness in Go)
|
||||
// Go strings are immutable, so this is mostly wishful thinking
|
||||
// But hey, at least we tried
|
||||
func ClearString(s *string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
// Best we can do - just set it to empty
|
||||
// The original memory might still be around somewhere, but GC will get it eventually
|
||||
*s = ""
|
||||
}
|
||||
265
passage.1.md
265
passage.1.md
@ -1,265 +0,0 @@
|
||||
% passage(1) PassAGE - A modern password manager using AGE encryption
|
||||
% PassAGE developers
|
||||
% 2024
|
||||
|
||||
# NAME
|
||||
|
||||
passage - A modern password manager using AGE encryption (PassAGE)
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**passage** [*command*] [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
passage is a command-line password manager that uses AGE (Actually Good Encryption)
|
||||
for secure password storage. It uses a master password model where a single password
|
||||
protects all stored passwords.
|
||||
|
||||
All passwords are encrypted using AGE's Scrypt passphrase encryption and stored
|
||||
in the password store directory (default: `~/.passage-store`).
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## init
|
||||
|
||||
**passage init** [**--path**=*subfolder*]
|
||||
|
||||
Initialize a new password store with a master password. Prompts for a master
|
||||
password that will be used to encrypt and decrypt all passwords.
|
||||
|
||||
**Options:**
|
||||
- **--path**, **-p** *subfolder* - Initialize store in a subfolder
|
||||
|
||||
## show, ls, list
|
||||
|
||||
**passage** [**show**|**ls**|**list**] [*subfolder*]
|
||||
|
||||
Display a password or list passwords in a directory.
|
||||
|
||||
**Options:**
|
||||
- **--clip**[=*line-number*], **-c**[=*line-number*] - Copy password (or specific line) to clipboard
|
||||
|
||||
## find, search
|
||||
|
||||
**passage find** *pass-names*...
|
||||
|
||||
Find passwords by name using pattern matching.
|
||||
|
||||
## grep
|
||||
|
||||
**passage grep** [*GREPOPTIONS*] *search-string*
|
||||
|
||||
Search for passwords containing *search-string* when decrypted.
|
||||
|
||||
## insert, add
|
||||
|
||||
**passage insert** [**--multiline**] [**--force**] *pass-name*
|
||||
|
||||
Insert a new password. Prompts for password input.
|
||||
|
||||
**Options:**
|
||||
- **--multiline**, **-m** - Allow multiline input (press Ctrl+D when finished)
|
||||
- **--force**, **-f** - Overwrite existing password without prompting
|
||||
|
||||
## edit
|
||||
|
||||
**passage edit** *pass-name*
|
||||
|
||||
Edit an existing password using the editor specified by the EDITOR environment
|
||||
variable (default: vi).
|
||||
|
||||
## generate
|
||||
|
||||
**passage generate** [**--no-symbols**] [**--clip**] [**--in-place**|**--force**] *pass-name* [*pass-length*]
|
||||
|
||||
Generate a new cryptographically secure random password.
|
||||
|
||||
**Options:**
|
||||
- **--no-symbols**, **-n** - Don't include symbols in generated password
|
||||
- **--clip**, **-c** - Copy generated password to clipboard
|
||||
- **--in-place**, **-i** - Replace first line of existing password
|
||||
- **--force**, **-f** - Overwrite existing password without prompting
|
||||
- *pass-length* - Length of password to generate (default: 25)
|
||||
|
||||
## rm, delete, remove
|
||||
|
||||
**passage rm** [**--recursive**] [**--force**] *pass-name*
|
||||
|
||||
Remove a password or directory.
|
||||
|
||||
**Options:**
|
||||
- **--recursive**, **-r** - Recursively remove directory
|
||||
- **--force**, **-f** - Remove without prompting
|
||||
|
||||
## mv, rename
|
||||
|
||||
**passage mv** [**--force**] *old-path* *new-path*
|
||||
|
||||
Move or rename a password.
|
||||
|
||||
**Options:**
|
||||
- **--force**, **-f** - Overwrite destination without prompting
|
||||
|
||||
## cp, copy
|
||||
|
||||
**passage cp** [**--force**] *old-path* *new-path*
|
||||
|
||||
Copy a password.
|
||||
|
||||
**Options:**
|
||||
- **--force**, **-f** - Overwrite destination without prompting
|
||||
|
||||
## backup
|
||||
|
||||
**passage backup** [**--output**=*file.tar.gz*]
|
||||
|
||||
Create a backup of the password store with integrity verification.
|
||||
|
||||
**Options:**
|
||||
- **--output**, **-o** *file.tar.gz* - Output backup file (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)
|
||||
|
||||
The backup includes a SHA256 checksum file for verification.
|
||||
|
||||
## restore
|
||||
|
||||
**passage restore** [**--force**] [**--skip-verify**] *backup-file.tar.gz*
|
||||
|
||||
Restore a backup of the password store.
|
||||
|
||||
**Options:**
|
||||
- **--force**, **-f** - Overwrite existing store without prompting
|
||||
- **--skip-verify**, **-s** - Skip checksum verification (not recommended)
|
||||
|
||||
## git
|
||||
|
||||
**passage git** *git-command-args*...
|
||||
|
||||
Run git commands in the password store directory. Useful for version control
|
||||
and syncing across devices.
|
||||
|
||||
**Examples:**
|
||||
- **passage git init** - Initialize git repository
|
||||
- **passage git push** - Push changes to remote
|
||||
- **passage git pull** - Pull changes from remote
|
||||
|
||||
## help
|
||||
|
||||
**passage help**
|
||||
|
||||
Show usage information.
|
||||
|
||||
## version
|
||||
|
||||
**passage version**
|
||||
|
||||
Show version information.
|
||||
|
||||
# ENVIRONMENT VARIABLES
|
||||
|
||||
**PASSAGE_DIR**
|
||||
Path to the password store directory. Defaults to `~/.passage-store` if not set.
|
||||
|
||||
**PASSAGE_CLIP_TIME**
|
||||
Time in seconds to keep password in clipboard before auto-clearing. Defaults to 10 seconds.
|
||||
|
||||
**PASSAGE_GENERATED_LENGTH**
|
||||
Default length for generated passwords. Defaults to 25 if not set.
|
||||
|
||||
**EDITOR**
|
||||
Editor to use for the **edit** command. Defaults to `vi` if not set.
|
||||
|
||||
**HOME**
|
||||
Home directory path. Used as base for default store location.
|
||||
|
||||
**WAYLAND_DISPLAY**
|
||||
If set, indicates Wayland display is available. Used for clipboard operations.
|
||||
|
||||
**DISPLAY**
|
||||
If set, indicates X11 display is available. Used for clipboard operations.
|
||||
|
||||
# FILES
|
||||
|
||||
**~/.passage-store/**
|
||||
Default password store directory. Contains encrypted password files (`.passage` extension)
|
||||
and the master password hash file (`.master-pass`).
|
||||
|
||||
**~/.passage-store/.master-pass**
|
||||
Stores Argon2id hash of master password for verification. Never contains the actual password.
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
Initialize a new password store:
|
||||
|
||||
$ passage init
|
||||
Initializing password store...
|
||||
Enter master password:
|
||||
Confirm master password:
|
||||
Password store initialized
|
||||
|
||||
Add a password:
|
||||
|
||||
$ passage insert example.com
|
||||
Enter master password:
|
||||
Enter password for example.com:
|
||||
Password for example.com added to store.
|
||||
|
||||
Show a password:
|
||||
|
||||
$ passage show example.com
|
||||
Enter master password:
|
||||
mypassword123
|
||||
|
||||
Generate a password:
|
||||
|
||||
$ passage generate example.com 32
|
||||
Enter master password:
|
||||
The generated password for example.com is:
|
||||
xK9#mP2$vL8@nQ4&wR7!tY5*uI3^oE6
|
||||
|
||||
Copy password to clipboard:
|
||||
|
||||
$ passage show --clip example.com
|
||||
Enter master password:
|
||||
Copied example.com to clipboard. Will clear in 10 seconds.
|
||||
|
||||
Create a backup:
|
||||
|
||||
$ passage backup
|
||||
Enter master password:
|
||||
Backup created: passage-backup-20240101-120000.tar.gz
|
||||
Files backed up: 15
|
||||
Checksum: a1b2c3d4e5f6...
|
||||
Checksum file: passage-backup-20240101-120000.tar.gz.sha256
|
||||
|
||||
# SECURITY
|
||||
|
||||
PassAGE uses AGE encryption with Scrypt passphrase encryption for all password files.
|
||||
The master password is verified using Argon2id hashing, which is memory-hard and
|
||||
resistant to brute-force attacks.
|
||||
|
||||
**Important security notes:**
|
||||
|
||||
- The master password is never stored in plaintext
|
||||
- All password files are encrypted individually
|
||||
- File permissions are set to 0600 (files) and 0700 (directories)
|
||||
- Clipboard is automatically cleared after the timeout period
|
||||
- Passwords are cleared from memory when possible
|
||||
|
||||
For detailed security information, see **SECURITY.md** in the PassAGE source code.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
**age**(1), **git**(1)
|
||||
|
||||
# BUGS
|
||||
|
||||
Report bugs at the project repository
|
||||
|
||||
# AUTHOR
|
||||
|
||||
PassAGE developers
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
This project uses AGE encryption. See LICENSE file for details.
|
||||
163
security.go
163
security.go
@ -1,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// checkSneakyPaths prevents path traversal attacks
|
||||
// Because users will try to break things, and we'd rather not let them
|
||||
func checkSneakyPaths(path string) error {
|
||||
if path == "" {
|
||||
return nil // Empty is fine
|
||||
}
|
||||
|
||||
// Classic path traversal attempt - catch it early
|
||||
if strings.Contains(path, "..") {
|
||||
return fmt.Errorf("error: path contains '..' - path traversal not allowed")
|
||||
}
|
||||
|
||||
// No absolute paths either - keep everything relative
|
||||
if filepath.IsAbs(path) {
|
||||
return fmt.Errorf("error: absolute paths not allowed")
|
||||
}
|
||||
|
||||
// Check if path would escape store directory
|
||||
storeDir := getStoreDir()
|
||||
absPath, err := filepath.Abs(filepath.Join(storeDir, path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error: invalid path: %w", err)
|
||||
}
|
||||
|
||||
absStoreDir, err := filepath.Abs(storeDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error: invalid store directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the resolved path is within the store directory
|
||||
relPath, err := filepath.Rel(absStoreDir, absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error: path outside store directory")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return fmt.Errorf("error: path escapes store directory")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizePath cleans and validates a password path
|
||||
func sanitizePath(path string) (string, error) {
|
||||
if err := checkSneakyPaths(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Clean up the path - normalize separators and such
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Trim slashes - they're not needed
|
||||
cleaned = strings.Trim(cleaned, "/")
|
||||
|
||||
// Reasonable length limit - 4KB should be enough for anyone (famous last words)
|
||||
const maxPathLength = 4096
|
||||
if len(cleaned) > maxPathLength {
|
||||
return "", fmt.Errorf("error: path too long (max %d characters)", maxPathLength)
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// maxFileSize limits the size of files we'll read into memory
|
||||
// 10MB seems reasonable - if you need bigger passwords, maybe rethink your life choices
|
||||
const maxFileSize = 10 * 1024 * 1024
|
||||
|
||||
// checkFileSize checks if a file is within size limits
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Size() > maxFileSize {
|
||||
return fmt.Errorf("error: file too large (max %d bytes)", maxFileSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPasswordLength limits password length to prevent DoS
|
||||
// 1MB should be enough for even the most paranoid password
|
||||
const maxPasswordLength = 1024 * 1024
|
||||
|
||||
// checkPasswordLength validates password length
|
||||
func checkPasswordLength(password string) error {
|
||||
if len(password) > maxPasswordLength {
|
||||
return fmt.Errorf("error: password too long (max %d bytes)", maxPasswordLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLimited reads up to maxBytes from a reader to prevent resource exhaustion
|
||||
func readLimited(r io.Reader, maxBytes int64) ([]byte, error) {
|
||||
limitedReader := io.LimitReader(r, maxBytes)
|
||||
data, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check if we hit the limit (would mean file is larger than max)
|
||||
if int64(len(data)) == maxBytes {
|
||||
// Try to read one more byte to see if there's more
|
||||
var buf [1]byte
|
||||
if n, _ := r.Read(buf[:]); n > 0 {
|
||||
return nil, fmt.Errorf("error: file too large (max %d bytes)", maxBytes)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// createSecureTempFile creates a temporary file in a secure location
|
||||
// Tries /dev/shm first (RAM-backed, faster, more secure), falls back to system temp
|
||||
func createSecureTempFile(prefix, suffix string) (string, error) {
|
||||
// Try /dev/shm - it's RAM-backed so it's faster and doesn't touch disk
|
||||
// Not available on all systems, but worth checking
|
||||
if dir := "/dev/shm"; isWritable(dir) {
|
||||
tmpFile, err := os.CreateTemp(dir, prefix)
|
||||
if err == nil {
|
||||
tmpFile.Close()
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
// If it fails, just continue to fallback
|
||||
}
|
||||
|
||||
// Fall back to whatever the OS thinks is a good temp directory
|
||||
tmpFile, err := os.CreateTemp("", prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpFile.Close()
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
// isWritable checks if a directory is writable
|
||||
func isWritable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
// Try to create a test file
|
||||
testFile := filepath.Join(path, ".passage-write-test")
|
||||
f, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(testFile)
|
||||
return true
|
||||
}
|
||||
346
store.go
346
store.go
@ -1,346 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStoreDir = ".passage-store"
|
||||
passageExt = ".passage"
|
||||
masterPassFile = ".master-pass" // Just a hash, not the actual password (obviously)
|
||||
)
|
||||
|
||||
// getStoreDir returns the password store directory path
|
||||
// Tries to be smart about where to put things, but falls back gracefully
|
||||
func getStoreDir() string {
|
||||
if dir := os.Getenv("PASSAGE_DIR"); dir != "" {
|
||||
// Paranoid check for path traversal - you never know what users will try
|
||||
if strings.Contains(dir, "..") {
|
||||
// Nope, not today. Fall back to default.
|
||||
return filepath.Join(os.Getenv("HOME"), defaultStoreDir)
|
||||
}
|
||||
// Make sure we have an absolute path
|
||||
if !filepath.IsAbs(dir) {
|
||||
// Relative paths are fine, just anchor them to HOME
|
||||
return filepath.Join(os.Getenv("HOME"), dir)
|
||||
}
|
||||
// Double-check that even absolute paths don't escape HOME
|
||||
// (because why would you want your passwords outside HOME anyway?)
|
||||
home := os.Getenv("HOME")
|
||||
if home != "" {
|
||||
rel, err := filepath.Rel(home, dir)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
// Nice try, but no. Default it is.
|
||||
return filepath.Join(home, defaultStoreDir)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// No custom dir set, use the default location
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Can't get home dir? Just use current directory I guess
|
||||
return defaultStoreDir
|
||||
}
|
||||
return filepath.Join(home, defaultStoreDir)
|
||||
}
|
||||
|
||||
// getMasterPassPath returns the path to the master password verification file
|
||||
func getMasterPassPath(dir string) string {
|
||||
return filepath.Join(dir, masterPassFile)
|
||||
}
|
||||
|
||||
// getMasterPassword prompts for the master password and returns SecureBytes
|
||||
// Tries to hide input if possible, but falls back gracefully for pipes/scripts
|
||||
func getMasterPassword(prompt string) (*SecureBytes, error) {
|
||||
if prompt == "" {
|
||||
prompt = "Enter master password: "
|
||||
}
|
||||
|
||||
// Try to hide the password if we're in a real terminal
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprint(os.Stderr, "\n")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
return NewSecureBytes(password), nil
|
||||
}
|
||||
|
||||
// Not a terminal (probably a pipe or script) - can't hide it, but at least it works
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(password)
|
||||
return NewSecureBytes([]byte(trimmed)), nil
|
||||
}
|
||||
|
||||
// checkStoreInitialized checks if the store is initialized
|
||||
func checkStoreInitialized(dir string) bool {
|
||||
masterPassPath := getMasterPassPath(dir)
|
||||
_, err := os.Stat(masterPassPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// getRecipientForEncryption returns a ScryptRecipient for encrypting with master password
|
||||
func getRecipientForEncryption(masterPassword []byte) (age.Recipient, error) {
|
||||
return age.NewScryptRecipient(string(masterPassword))
|
||||
}
|
||||
|
||||
// getIdentityForDecryption returns a ScryptIdentity for decrypting with master password
|
||||
func getIdentityForDecryption(masterPassword []byte) (age.Identity, error) {
|
||||
return age.NewScryptIdentity(string(masterPassword))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// hashPassword creates an Argon2id hash of the password for verification
|
||||
// Returns base64-encoded salt:hash
|
||||
func hashPassword(password []byte) (string, error) {
|
||||
// Generate random salt - makes each hash unique even for same password
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// Argon2id with reasonable defaults:
|
||||
// - 3 iterations (fast enough, slow enough for attackers)
|
||||
// - 32 MB memory (makes GPU attacks expensive)
|
||||
// - 4 threads (parallelism)
|
||||
// - 32 bytes output (standard hash size)
|
||||
hash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
|
||||
|
||||
// Encode as base64: salt:hash
|
||||
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
||||
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
|
||||
return saltB64 + ":" + hashB64, nil
|
||||
}
|
||||
|
||||
// verifyMasterPassword checks if the provided password matches the stored hash
|
||||
func verifyMasterPassword(storeDir string, password []byte) (bool, error) {
|
||||
masterPassPath := getMasterPassPath(storeDir)
|
||||
data, err := os.ReadFile(masterPassPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stored := strings.TrimSpace(string(data))
|
||||
|
||||
// Parse stored salt:hash
|
||||
parts := strings.Split(stored, ":")
|
||||
if len(parts) != 2 {
|
||||
// Legacy SHA256 format - try to verify and migrate
|
||||
return verifyLegacySHA256(password, stored)
|
||||
}
|
||||
|
||||
saltB64 := parts[0]
|
||||
expectedHashB64 := parts[1]
|
||||
|
||||
// Decode salt and expected hash
|
||||
salt, err := base64.RawStdEncoding.DecodeString(saltB64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to decode salt: %w", err)
|
||||
}
|
||||
|
||||
expectedHash, err := base64.RawStdEncoding.DecodeString(expectedHashB64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to decode hash: %w", err)
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
|
||||
|
||||
// Constant-time comparison
|
||||
return constantTimeCompare(computedHash, expectedHash), nil
|
||||
}
|
||||
|
||||
// verifyLegacySHA256 verifies against old SHA256 hashes (for migration)
|
||||
// Kept for backwards compatibility with stores created before Argon2id migration
|
||||
func verifyLegacySHA256(password []byte, storedHash string) (bool, error) {
|
||||
// Old stores used SHA256 - not ideal but we support it for migration
|
||||
hash := sha256Sum(password)
|
||||
providedHash := hex.EncodeToString(hash[:])
|
||||
|
||||
// Constant-time comparison - can't let attackers time their way in
|
||||
return constantTimeStringCompare(providedHash, storedHash), nil
|
||||
}
|
||||
|
||||
// sha256Sum computes SHA256 hash (for legacy verification only)
|
||||
func sha256Sum(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// constantTimeCompare performs constant-time comparison of two byte slices
|
||||
func constantTimeCompare(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// constantTimeStringCompare performs constant-time comparison of two strings
|
||||
func constantTimeStringCompare(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// storeMasterPasswordHash stores an Argon2id hash of the master password for verification
|
||||
func storeMasterPasswordHash(storeDir string, password []byte) error {
|
||||
masterPassPath := getMasterPassPath(storeDir)
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return os.WriteFile(masterPassPath, []byte(hash), 0600)
|
||||
}
|
||||
|
||||
// encryptFile encrypts a file using AGE with passphrase encryption
|
||||
// Uses AGE's scrypt encryption - simple but effective
|
||||
func encryptFile(inputPath, outputPath string, masterPassword []byte) error {
|
||||
recipient, err := getRecipientForEncryption(masterPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create recipient: %w", err)
|
||||
}
|
||||
|
||||
var input io.Reader
|
||||
if inputPath == "" || inputPath == "-" {
|
||||
input = os.Stdin
|
||||
} else {
|
||||
file, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
input = file
|
||||
}
|
||||
|
||||
output, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer output.Close()
|
||||
|
||||
encrypted, err := age.Encrypt(output, recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(encrypted, input); err != nil {
|
||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
if err := encrypted.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptFile decrypts an AGE-encrypted file using master password
|
||||
// If outputPath is empty or "-", returns a reader for the decrypted content
|
||||
// If outputPath is specified, writes decrypted content to file and returns nil reader
|
||||
func decryptFile(inputPath, outputPath string, masterPassword []byte) (io.Reader, error) {
|
||||
identity, err := getIdentityForDecryption(masterPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create identity: %w", err)
|
||||
}
|
||||
|
||||
var input io.Reader
|
||||
var file *os.File
|
||||
if inputPath == "" || inputPath == "-" {
|
||||
input = os.Stdin
|
||||
} else {
|
||||
file, err = os.Open(inputPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input = file
|
||||
}
|
||||
|
||||
decrypted, err := age.Decrypt(input, identity)
|
||||
if err != nil {
|
||||
if file != nil {
|
||||
file.Close()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
// If output path specified, write to file and close everything
|
||||
if outputPath != "" && outputPath != "-" {
|
||||
outputFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
if file != nil {
|
||||
file.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer outputFile.Close()
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if _, err := io.Copy(outputFile, decrypted); err != nil {
|
||||
return nil, fmt.Errorf("failed to write decrypted data: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return a reader that keeps the file open until reading is complete
|
||||
// This was a fun bug to track down - file was closing too early!
|
||||
// Now fileReader handles cleanup properly
|
||||
if file == nil {
|
||||
// Reading from stdin - no file to manage
|
||||
return decrypted, nil
|
||||
}
|
||||
return &fileReader{
|
||||
file: file,
|
||||
reader: decrypted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fileReader wraps a reader and keeps the underlying file open until reading completes
|
||||
type fileReader struct {
|
||||
file *os.File
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (fr *fileReader) Read(p []byte) (n int, err error) {
|
||||
if fr == nil || fr.reader == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n, err = fr.reader.Read(p)
|
||||
if err == io.EOF || err != nil {
|
||||
// Clean up when we're done - don't leak file handles
|
||||
if fr.file != nil {
|
||||
fr.file.Close()
|
||||
fr.file = nil
|
||||
}
|
||||
fr.reader = nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user