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
|
# PassAGE
|
||||||
|
|
||||||
A modern password manager using AGE encryption.
|
PassAGE is a command-line password manager that uses AGE (Actually Good Encryption) for secure password
|
||||||
|
storage.
|
||||||
**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.
|
|
||||||
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