Compare commits
14 Commits
32db394b1e
...
81f0030069
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f0030069 | ||
|
|
3d95fd6363 | ||
|
|
c6e3c35a80 | ||
|
|
05bab4b9c9 | ||
|
|
46a22138b9 | ||
|
|
e8c9c74b96 | ||
|
|
4242415af9 | ||
|
|
856d48249e | ||
|
|
ca4c86546b | ||
|
|
388c14426c | ||
| f4f7b50e98 | |||
|
|
278cacc43e | ||
|
|
3552db50c2 | ||
| cb3ddc03e5 |
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Binaries
|
||||||
|
passage
|
||||||
|
passage.exe
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
passage.1
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.local
|
||||||
73
CONTRIBUTING.md
Normal file
73
CONTRIBUTING.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 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
Normal file
173
INSTALL.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 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
Normal file
63
Makefile
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
.PHONY: build install clean test release man install-man
|
||||||
|
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
PREFIX ?= /usr/local
|
||||||
|
MANPREFIX ?= $(PREFIX)/share/man
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o passage .
|
||||||
|
|
||||||
|
build-release:
|
||||||
|
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o passage .
|
||||||
|
|
||||||
|
install: build
|
||||||
|
@mkdir -p $(DESTDIR)$(PREFIX)/bin
|
||||||
|
cp passage $(DESTDIR)$(PREFIX)/bin/passage
|
||||||
|
@echo "Installed passage to $(DESTDIR)$(PREFIX)/bin/passage"
|
||||||
|
|
||||||
|
install-user: build
|
||||||
|
@mkdir -p ~/.local/bin
|
||||||
|
cp passage ~/.local/bin/passage
|
||||||
|
@echo "Installed passage to ~/.local/bin/passage"
|
||||||
|
|
||||||
|
man: passage.1
|
||||||
|
|
||||||
|
passage.1: passage.1.md
|
||||||
|
@if command -v pandoc >/dev/null 2>&1; then \
|
||||||
|
pandoc passage.1.md -s -t man -o passage.1; \
|
||||||
|
elif command -v go-md2man >/dev/null 2>&1; then \
|
||||||
|
go-md2man -in passage.1.md -out passage.1; \
|
||||||
|
else \
|
||||||
|
echo "Error: pandoc or go-md2man required to build manpage"; \
|
||||||
|
echo "Install pandoc: sudo apt-get install pandoc (Debian/Ubuntu)"; \
|
||||||
|
echo "Or install go-md2man: go install github.com/cpuguy83/go-md2man/v2@latest"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
install-man: man
|
||||||
|
@mkdir -p $(DESTDIR)$(MANPREFIX)/man1
|
||||||
|
cp passage.1 $(DESTDIR)$(MANPREFIX)/man1/passage.1
|
||||||
|
@if command -v mandb >/dev/null 2>&1; then \
|
||||||
|
mandb $(DESTDIR)$(MANPREFIX) >/dev/null 2>&1 || true; \
|
||||||
|
fi
|
||||||
|
@echo "Installed manpage to $(DESTDIR)$(MANPREFIX)/man1/passage.1"
|
||||||
|
|
||||||
|
install-user-man: man
|
||||||
|
@mkdir -p ~/.local/share/man/man1
|
||||||
|
cp passage.1 ~/.local/share/man/man1/passage.1
|
||||||
|
@if command -v mandb >/dev/null 2>&1; then \
|
||||||
|
mandb ~/.local/share/man >/dev/null 2>&1 || true; \
|
||||||
|
fi
|
||||||
|
@echo "Installed manpage to ~/.local/share/man/man1/passage.1"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f passage passage.1
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
test-race:
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
release: build-release
|
||||||
|
@echo "Built release version: $(VERSION)"
|
||||||
220
README.md
220
README.md
@ -1,4 +1,220 @@
|
|||||||
# PassAGE
|
# PassAGE
|
||||||
|
|
||||||
PassAGE is a command-line password manager that uses AGE (Actually Good Encryption) for secure password
|
A modern password manager using AGE encryption.
|
||||||
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
Normal file
42
SECURITY.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Security Considerations for PassAGE
|
||||||
|
|
||||||
|
This document describes PassAGE's security model and implementation details.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
PassAGE uses a master password model with AGE's passphrase encryption (Scrypt).
|
||||||
|
|
||||||
|
### Master Password Storage
|
||||||
|
|
||||||
|
The master password is **never stored in plaintext**. Instead:
|
||||||
|
|
||||||
|
1. **Argon2id hash**: A one-way hash is stored in `.master-pass` file in the password store directory
|
||||||
|
- Uses Argon2id (winner of Password Hashing Competition)
|
||||||
|
- Parameters: 3 iterations, 32MB memory, 4 threads, 32-byte output
|
||||||
|
- Includes random salt (16 bytes) for each password
|
||||||
|
- Format: `salt:hash` (base64 encoded)
|
||||||
|
|
||||||
|
2. **Verification**: When you enter the master password:
|
||||||
|
- A new hash is computed with the same salt
|
||||||
|
- Compared with stored hash using constant-time comparison
|
||||||
|
- If match, password is used for encryption/decryption
|
||||||
|
|
||||||
|
3. **Security properties**:
|
||||||
|
- **Memory-hard**: Resistant to GPU/ASIC attacks
|
||||||
|
- **Slow by design**: Makes brute force attacks expensive
|
||||||
|
- **Salt**: Prevents rainbow table attacks
|
||||||
|
- **One-way**: Hash cannot be reversed to get password
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
All passwords are encrypted using:
|
||||||
|
- **AGE Scrypt encryption**: Industry-standard passphrase encryption
|
||||||
|
- **Master password**: Used directly for encryption (not stored)
|
||||||
|
- **File format**: AGE v1 encrypted files (`.passage` extension)
|
||||||
|
|
||||||
|
### Security Model
|
||||||
|
|
||||||
|
- Master password protects all stored passwords
|
||||||
|
- Hash file is only for verification (cannot recover password)
|
||||||
|
- Full disk encryption recommended for additional protection
|
||||||
|
- File permissions: `.master-pass` stored with 0600 permissions
|
||||||
306
backup.go
Normal file
306
backup.go
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdBackup creates a backup of the password store
|
||||||
|
func cmdBackup(args []string) {
|
||||||
|
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||||
|
outputFlag := fs.String("output", "", "output backup file path (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)")
|
||||||
|
outputShort := fs.String("o", "", "output backup file path (short)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeDir := getStoreDir()
|
||||||
|
if !checkStoreInitialized(storeDir) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: password store not initialized. Run 'passage init' first\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output file
|
||||||
|
outputFile := *outputFlag
|
||||||
|
if outputFile == "" {
|
||||||
|
outputFile = *outputShort
|
||||||
|
}
|
||||||
|
if outputFile == "" {
|
||||||
|
// Generate default filename with timestamp
|
||||||
|
timestamp := time.Now().Format("20060102-150405")
|
||||||
|
outputFile = fmt.Sprintf("passage-backup-%s.tar.gz", timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate output path
|
||||||
|
if err := checkSneakyPaths(outputFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid output path: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file
|
||||||
|
backupFile, err := os.Create(outputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to create backup file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer backupFile.Close()
|
||||||
|
|
||||||
|
// Create gzip writer
|
||||||
|
gzipWriter := gzip.NewWriter(backupFile)
|
||||||
|
defer gzipWriter.Close()
|
||||||
|
|
||||||
|
// Create tar writer
|
||||||
|
tarWriter := tar.NewWriter(gzipWriter)
|
||||||
|
defer tarWriter.Close()
|
||||||
|
|
||||||
|
// Calculate checksum while writing
|
||||||
|
hash := sha256.New()
|
||||||
|
multiWriter := io.MultiWriter(tarWriter, hash)
|
||||||
|
|
||||||
|
// Walk the store directory and add files to tar
|
||||||
|
fileCount := 0
|
||||||
|
err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip .git - it's usually huge and users can backup it separately if they want
|
||||||
|
if info.IsDir() && strings.Contains(path, ".git") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories themselves
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate relative path
|
||||||
|
relPath, err := filepath.Rel(storeDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
header.Mode = int64(info.Mode())
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file and copy contents
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Copy file contents through multi-writer (to tar and hash)
|
||||||
|
if _, err := io.Copy(multiWriter, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize tar and gzip
|
||||||
|
tarWriter.Close()
|
||||||
|
gzipWriter.Close()
|
||||||
|
backupFile.Close()
|
||||||
|
|
||||||
|
// Calculate final checksum
|
||||||
|
checksum := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
|
||||||
|
// Write checksum file for verification later
|
||||||
|
checksumFile := outputFile + ".sha256"
|
||||||
|
if err := os.WriteFile(checksumFile, []byte(checksum+" "+filepath.Base(outputFile)+"\n"), 0644); err != nil {
|
||||||
|
// Checksum file failed? Not critical, but warn about it
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to write checksum file: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Backup created: %s\n", outputFile)
|
||||||
|
fmt.Printf("Files backed up: %d\n", fileCount)
|
||||||
|
fmt.Printf("Checksum: %s\n", checksum)
|
||||||
|
fmt.Printf("Checksum file: %s\n", checksumFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdRestore restores a backup of the password store
|
||||||
|
func cmdRestore(args []string) {
|
||||||
|
fs := flag.NewFlagSet("restore", flag.ExitOnError)
|
||||||
|
force := fs.Bool("force", false, "overwrite existing store without prompting")
|
||||||
|
forceShort := fs.Bool("f", false, "overwrite existing store without prompting (short)")
|
||||||
|
skipVerify := fs.Bool("skip-verify", false, "skip checksum verification")
|
||||||
|
skipVerifyShort := fs.Bool("s", false, "skip checksum verification (short)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: passage restore [--force] [--skip-verify] backup-file.tar.gz\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFile := fs.Arg(0)
|
||||||
|
|
||||||
|
// Validate backup file path
|
||||||
|
if err := checkSneakyPaths(backupFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid backup file path: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if backup file exists
|
||||||
|
if _, err := os.Stat(backupFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: backup file not found: %s\n", backupFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum if not skipped (you really shouldn't skip this)
|
||||||
|
if !*skipVerify && !*skipVerifyShort {
|
||||||
|
checksumFile := backupFile + ".sha256"
|
||||||
|
if _, err := os.Stat(checksumFile); err == nil {
|
||||||
|
expectedChecksumData, err := os.ReadFile(checksumFile)
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(expectedChecksumData)), " ")
|
||||||
|
if len(lines) >= 1 {
|
||||||
|
expectedChecksum := strings.TrimSpace(lines[0])
|
||||||
|
if !verifyBackupChecksum(backupFile, expectedChecksum) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: backup file checksum verification failed!\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore anyway (not recommended)\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Checksum verified: OK\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No checksum file? That's suspicious - require explicit skip
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: checksum file not found: %s\n", checksumFile)
|
||||||
|
fmt.Fprintf(os.Stderr, "Use --skip-verify to restore without verification\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storeDir := getStoreDir()
|
||||||
|
|
||||||
|
// Check if store already exists
|
||||||
|
if checkStoreInitialized(storeDir) {
|
||||||
|
if !*force && !*forceShort {
|
||||||
|
fmt.Printf("Warning: password store already exists at %s\n", storeDir)
|
||||||
|
fmt.Printf("Restoring will overwrite existing files. Continue? [y/N] ")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
response, _ := reader.ReadString('\n')
|
||||||
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
if response != "y" && response != "yes" {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open backup file
|
||||||
|
file, err := os.Open(backupFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to open backup file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Create gzip reader
|
||||||
|
gzipReader, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to read gzip archive: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
|
||||||
|
// Create tar reader
|
||||||
|
tarReader := tar.NewReader(gzipReader)
|
||||||
|
|
||||||
|
// Extract files one by one
|
||||||
|
fileCount := 0
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break // All done
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to read tar archive: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paranoid path check - even in backups, we don't trust paths
|
||||||
|
if err := checkSneakyPaths(header.Name); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid path in backup: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full path
|
||||||
|
targetPath := filepath.Join(storeDir, header.Name)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to create file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to write file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile.Close()
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Restore completed: %d files restored\n", fileCount)
|
||||||
|
fmt.Printf("Store location: %s\n", storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyBackupChecksum verifies the SHA256 checksum of a backup file
|
||||||
|
func verifyBackupChecksum(backupFile, expectedChecksum string) bool {
|
||||||
|
file, err := os.Open(backupFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
actualChecksum := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
return constantTimeStringCompare(actualChecksum, expectedChecksum)
|
||||||
|
}
|
||||||
78
clipboard.go
Normal file
78
clipboard.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clearClipboardAfter clears the clipboard after a delay
|
||||||
|
// Runs in a goroutine so it doesn't block
|
||||||
|
func clearClipboardAfter(duration time.Duration) {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(duration)
|
||||||
|
clearClipboard()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearClipboard clears the clipboard
|
||||||
|
// Tries both Wayland and X11 - whichever is available
|
||||||
|
func clearClipboard() {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||||
|
cmd = exec.Command("wl-copy", "--clear")
|
||||||
|
} else if os.Getenv("DISPLAY") != "" {
|
||||||
|
// X11 - clear by copying /dev/null (hacky but works)
|
||||||
|
cmd = exec.Command("xclip", "-selection", "clipboard", "/dev/null")
|
||||||
|
} else {
|
||||||
|
return // No clipboard available, nothing to clear
|
||||||
|
}
|
||||||
|
cmd.Run() // Best effort - if it fails, oh well
|
||||||
|
}
|
||||||
|
|
||||||
|
// clipToClipboard copies text to clipboard and sets up auto-clear
|
||||||
|
func clipToClipboard(text, path string) {
|
||||||
|
clipTime := 10
|
||||||
|
if env := os.Getenv("PASSAGE_CLIP_TIME"); env != "" {
|
||||||
|
if n, err := fmt.Sscanf(env, "%d", &clipTime); err != nil || n != 1 || clipTime < 0 || clipTime > 3600 {
|
||||||
|
clipTime = 10 // Reset to default on invalid input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try xclip (X11) or wl-copy (Wayland)
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||||
|
cmd = exec.Command("wl-copy")
|
||||||
|
} else if os.Getenv("DISPLAY") != "" {
|
||||||
|
cmd = exec.Command("xclip", "-selection", "clipboard")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: no clipboard available (needs X11 or Wayland)\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Stdin = strings.NewReader(text)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to copy to clipboard: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Copied %s to clipboard. Will clear in %d seconds.\n", path, clipTime)
|
||||||
|
|
||||||
|
// Set up signal handler to clear clipboard if user hits Ctrl+C
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Auto-clear after the timeout
|
||||||
|
clearClipboardAfter(time.Duration(clipTime) * time.Second)
|
||||||
|
|
||||||
|
// Also clear if interrupted - don't leave passwords lying around
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
clearClipboard()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
}
|
||||||
1213
commands.go
Normal file
1213
commands.go
Normal file
File diff suppressed because it is too large
Load Diff
11
go.mod
Normal file
11
go.mod
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module passage
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/age v1.1.1
|
||||||
|
golang.org/x/crypto v0.21.0
|
||||||
|
golang.org/x/term v0.19.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.19.0 // indirect
|
||||||
8
go.sum
Normal file
8
go.sum
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
|
||||||
|
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
|
||||||
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||||
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||||
|
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||||
97
main.go
Normal file
97
main.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "0.1.0"
|
||||||
|
|
||||||
|
const usage = `PassAGE - A modern password manager using AGE encryption
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
passage init [--path=subfolder]
|
||||||
|
Initialize new password storage with a master password.
|
||||||
|
Prompts for master password (used to encrypt/decrypt all passwords).
|
||||||
|
passage [ls] [subfolder]
|
||||||
|
List passwords.
|
||||||
|
passage find pass-names...
|
||||||
|
List passwords that match pass-names.
|
||||||
|
passage [show] [--clip[=line-number]] pass-name
|
||||||
|
Show existing password and optionally put it on the clipboard.
|
||||||
|
passage grep [GREPOPTIONS] search-string
|
||||||
|
Search for password files containing search-string when decrypted.
|
||||||
|
passage insert [--multiline] [--force] pass-name
|
||||||
|
Insert new password. Optionally, the entry may be multiline.
|
||||||
|
passage edit pass-name
|
||||||
|
Insert a new password or edit an existing password using ${EDITOR:-vi}.
|
||||||
|
passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]
|
||||||
|
Generate a new password of pass-length (or 25 if unspecified).
|
||||||
|
passage rm [--recursive] [--force] pass-name
|
||||||
|
Remove existing password or directory, optionally forcefully.
|
||||||
|
passage mv [--force] old-path new-path
|
||||||
|
Renames or moves old-path to new-path, optionally forcefully.
|
||||||
|
passage cp [--force] old-path new-path
|
||||||
|
Copies old-path to new-path, optionally forcefully.
|
||||||
|
passage backup [--output=file.tar.gz]
|
||||||
|
Create a backup of the password store with integrity verification.
|
||||||
|
passage restore [--force] [--skip-verify] backup-file.tar.gz
|
||||||
|
Restore a backup of the password store with integrity verification.
|
||||||
|
passage git git-command-args...
|
||||||
|
If the password store is a git repository, execute a git command.
|
||||||
|
passage help
|
||||||
|
Show this text.
|
||||||
|
passage version
|
||||||
|
Show version information.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
PASSAGE_DIR Path to the password store (default: ~/.passage-store)
|
||||||
|
PASSAGE_CLIP_TIME Time in seconds to keep password in clipboard (default: 10)
|
||||||
|
PASSAGE_GENERATED_LENGTH Default length for generated passwords (default: 25)
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
cmdShow([]string{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "init":
|
||||||
|
cmdInit(args)
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
fmt.Print(usage)
|
||||||
|
case "version", "--version", "-v":
|
||||||
|
fmt.Printf("PassAGE version %s\n", version)
|
||||||
|
case "show", "ls", "list":
|
||||||
|
cmdShow(args)
|
||||||
|
case "find", "search":
|
||||||
|
cmdFind(args)
|
||||||
|
case "grep":
|
||||||
|
cmdGrep(args)
|
||||||
|
case "insert", "add":
|
||||||
|
cmdInsert(args)
|
||||||
|
case "edit":
|
||||||
|
cmdEdit(args)
|
||||||
|
case "generate":
|
||||||
|
cmdGenerate(args)
|
||||||
|
case "delete", "rm", "remove":
|
||||||
|
cmdDelete(args)
|
||||||
|
case "rename", "mv":
|
||||||
|
cmdMove(args)
|
||||||
|
case "copy", "cp":
|
||||||
|
cmdCopy(args)
|
||||||
|
case "backup":
|
||||||
|
cmdBackup(args)
|
||||||
|
case "restore":
|
||||||
|
cmdRestore(args)
|
||||||
|
case "git":
|
||||||
|
cmdGit(args)
|
||||||
|
default:
|
||||||
|
// Try as show command
|
||||||
|
cmdShow(os.Args[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
68
memory.go
Normal file
68
memory.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecureBytes wraps a byte slice and provides secure clearing
|
||||||
|
// Because Go strings are immutable and we can't really clear them, this helps
|
||||||
|
type SecureBytes struct {
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecureBytes creates a new SecureBytes from a byte slice
|
||||||
|
func NewSecureBytes(data []byte) *SecureBytes {
|
||||||
|
return &SecureBytes{data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns the underlying byte slice
|
||||||
|
func (sb *SecureBytes) Bytes() []byte {
|
||||||
|
return sb.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation (use with caution)
|
||||||
|
// This uses unsafe.Pointer magic - don't look too closely, it works
|
||||||
|
func (sb *SecureBytes) String() string {
|
||||||
|
return *(*string)(unsafe.Pointer(&sb.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear securely zeros the memory
|
||||||
|
// Overwrites with random junk first (defense in depth), then zeros
|
||||||
|
// Not perfect (Go GC might move things around), but better than nothing
|
||||||
|
func (sb *SecureBytes) Clear() {
|
||||||
|
if sb == nil || sb.data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fill with random data first - makes memory dumps less useful
|
||||||
|
rand.Read(sb.data)
|
||||||
|
// Then zero it out
|
||||||
|
for i := range sb.data {
|
||||||
|
sb.data[i] = 0
|
||||||
|
}
|
||||||
|
sb.data = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearBytes securely clears a byte slice
|
||||||
|
func ClearBytes(data []byte) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Overwrite with random data first, then zeros
|
||||||
|
rand.Read(data)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearString attempts to clear a string (limited effectiveness in Go)
|
||||||
|
// Go strings are immutable, so this is mostly wishful thinking
|
||||||
|
// But hey, at least we tried
|
||||||
|
func ClearString(s *string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Best we can do - just set it to empty
|
||||||
|
// The original memory might still be around somewhere, but GC will get it eventually
|
||||||
|
*s = ""
|
||||||
|
}
|
||||||
265
passage.1.md
Normal file
265
passage.1.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
% passage(1) PassAGE - A modern password manager using AGE encryption
|
||||||
|
% PassAGE developers
|
||||||
|
% 2024
|
||||||
|
|
||||||
|
# NAME
|
||||||
|
|
||||||
|
passage - A modern password manager using AGE encryption (PassAGE)
|
||||||
|
|
||||||
|
# SYNOPSIS
|
||||||
|
|
||||||
|
**passage** [*command*] [*options*] [*arguments*]
|
||||||
|
|
||||||
|
# DESCRIPTION
|
||||||
|
|
||||||
|
passage is a command-line password manager that uses AGE (Actually Good Encryption)
|
||||||
|
for secure password storage. It uses a master password model where a single password
|
||||||
|
protects all stored passwords.
|
||||||
|
|
||||||
|
All passwords are encrypted using AGE's Scrypt passphrase encryption and stored
|
||||||
|
in the password store directory (default: `~/.passage-store`).
|
||||||
|
|
||||||
|
# COMMANDS
|
||||||
|
|
||||||
|
## init
|
||||||
|
|
||||||
|
**passage init** [**--path**=*subfolder*]
|
||||||
|
|
||||||
|
Initialize a new password store with a master password. Prompts for a master
|
||||||
|
password that will be used to encrypt and decrypt all passwords.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--path**, **-p** *subfolder* - Initialize store in a subfolder
|
||||||
|
|
||||||
|
## show, ls, list
|
||||||
|
|
||||||
|
**passage** [**show**|**ls**|**list**] [*subfolder*]
|
||||||
|
|
||||||
|
Display a password or list passwords in a directory.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--clip**[=*line-number*], **-c**[=*line-number*] - Copy password (or specific line) to clipboard
|
||||||
|
|
||||||
|
## find, search
|
||||||
|
|
||||||
|
**passage find** *pass-names*...
|
||||||
|
|
||||||
|
Find passwords by name using pattern matching.
|
||||||
|
|
||||||
|
## grep
|
||||||
|
|
||||||
|
**passage grep** [*GREPOPTIONS*] *search-string*
|
||||||
|
|
||||||
|
Search for passwords containing *search-string* when decrypted.
|
||||||
|
|
||||||
|
## insert, add
|
||||||
|
|
||||||
|
**passage insert** [**--multiline**] [**--force**] *pass-name*
|
||||||
|
|
||||||
|
Insert a new password. Prompts for password input.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--multiline**, **-m** - Allow multiline input (press Ctrl+D when finished)
|
||||||
|
- **--force**, **-f** - Overwrite existing password without prompting
|
||||||
|
|
||||||
|
## edit
|
||||||
|
|
||||||
|
**passage edit** *pass-name*
|
||||||
|
|
||||||
|
Edit an existing password using the editor specified by the EDITOR environment
|
||||||
|
variable (default: vi).
|
||||||
|
|
||||||
|
## generate
|
||||||
|
|
||||||
|
**passage generate** [**--no-symbols**] [**--clip**] [**--in-place**|**--force**] *pass-name* [*pass-length*]
|
||||||
|
|
||||||
|
Generate a new cryptographically secure random password.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--no-symbols**, **-n** - Don't include symbols in generated password
|
||||||
|
- **--clip**, **-c** - Copy generated password to clipboard
|
||||||
|
- **--in-place**, **-i** - Replace first line of existing password
|
||||||
|
- **--force**, **-f** - Overwrite existing password without prompting
|
||||||
|
- *pass-length* - Length of password to generate (default: 25)
|
||||||
|
|
||||||
|
## rm, delete, remove
|
||||||
|
|
||||||
|
**passage rm** [**--recursive**] [**--force**] *pass-name*
|
||||||
|
|
||||||
|
Remove a password or directory.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--recursive**, **-r** - Recursively remove directory
|
||||||
|
- **--force**, **-f** - Remove without prompting
|
||||||
|
|
||||||
|
## mv, rename
|
||||||
|
|
||||||
|
**passage mv** [**--force**] *old-path* *new-path*
|
||||||
|
|
||||||
|
Move or rename a password.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--force**, **-f** - Overwrite destination without prompting
|
||||||
|
|
||||||
|
## cp, copy
|
||||||
|
|
||||||
|
**passage cp** [**--force**] *old-path* *new-path*
|
||||||
|
|
||||||
|
Copy a password.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--force**, **-f** - Overwrite destination without prompting
|
||||||
|
|
||||||
|
## backup
|
||||||
|
|
||||||
|
**passage backup** [**--output**=*file.tar.gz*]
|
||||||
|
|
||||||
|
Create a backup of the password store with integrity verification.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--output**, **-o** *file.tar.gz* - Output backup file (default: passage-backup-YYYYMMDD-HHMMSS.tar.gz)
|
||||||
|
|
||||||
|
The backup includes a SHA256 checksum file for verification.
|
||||||
|
|
||||||
|
## restore
|
||||||
|
|
||||||
|
**passage restore** [**--force**] [**--skip-verify**] *backup-file.tar.gz*
|
||||||
|
|
||||||
|
Restore a backup of the password store.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- **--force**, **-f** - Overwrite existing store without prompting
|
||||||
|
- **--skip-verify**, **-s** - Skip checksum verification (not recommended)
|
||||||
|
|
||||||
|
## git
|
||||||
|
|
||||||
|
**passage git** *git-command-args*...
|
||||||
|
|
||||||
|
Run git commands in the password store directory. Useful for version control
|
||||||
|
and syncing across devices.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- **passage git init** - Initialize git repository
|
||||||
|
- **passage git push** - Push changes to remote
|
||||||
|
- **passage git pull** - Pull changes from remote
|
||||||
|
|
||||||
|
## help
|
||||||
|
|
||||||
|
**passage help**
|
||||||
|
|
||||||
|
Show usage information.
|
||||||
|
|
||||||
|
## version
|
||||||
|
|
||||||
|
**passage version**
|
||||||
|
|
||||||
|
Show version information.
|
||||||
|
|
||||||
|
# ENVIRONMENT VARIABLES
|
||||||
|
|
||||||
|
**PASSAGE_DIR**
|
||||||
|
Path to the password store directory. Defaults to `~/.passage-store` if not set.
|
||||||
|
|
||||||
|
**PASSAGE_CLIP_TIME**
|
||||||
|
Time in seconds to keep password in clipboard before auto-clearing. Defaults to 10 seconds.
|
||||||
|
|
||||||
|
**PASSAGE_GENERATED_LENGTH**
|
||||||
|
Default length for generated passwords. Defaults to 25 if not set.
|
||||||
|
|
||||||
|
**EDITOR**
|
||||||
|
Editor to use for the **edit** command. Defaults to `vi` if not set.
|
||||||
|
|
||||||
|
**HOME**
|
||||||
|
Home directory path. Used as base for default store location.
|
||||||
|
|
||||||
|
**WAYLAND_DISPLAY**
|
||||||
|
If set, indicates Wayland display is available. Used for clipboard operations.
|
||||||
|
|
||||||
|
**DISPLAY**
|
||||||
|
If set, indicates X11 display is available. Used for clipboard operations.
|
||||||
|
|
||||||
|
# FILES
|
||||||
|
|
||||||
|
**~/.passage-store/**
|
||||||
|
Default password store directory. Contains encrypted password files (`.passage` extension)
|
||||||
|
and the master password hash file (`.master-pass`).
|
||||||
|
|
||||||
|
**~/.passage-store/.master-pass**
|
||||||
|
Stores Argon2id hash of master password for verification. Never contains the actual password.
|
||||||
|
|
||||||
|
# EXAMPLES
|
||||||
|
|
||||||
|
Initialize a new password store:
|
||||||
|
|
||||||
|
$ passage init
|
||||||
|
Initializing password store...
|
||||||
|
Enter master password:
|
||||||
|
Confirm master password:
|
||||||
|
Password store initialized
|
||||||
|
|
||||||
|
Add a password:
|
||||||
|
|
||||||
|
$ passage insert example.com
|
||||||
|
Enter master password:
|
||||||
|
Enter password for example.com:
|
||||||
|
Password for example.com added to store.
|
||||||
|
|
||||||
|
Show a password:
|
||||||
|
|
||||||
|
$ passage show example.com
|
||||||
|
Enter master password:
|
||||||
|
mypassword123
|
||||||
|
|
||||||
|
Generate a password:
|
||||||
|
|
||||||
|
$ passage generate example.com 32
|
||||||
|
Enter master password:
|
||||||
|
The generated password for example.com is:
|
||||||
|
xK9#mP2$vL8@nQ4&wR7!tY5*uI3^oE6
|
||||||
|
|
||||||
|
Copy password to clipboard:
|
||||||
|
|
||||||
|
$ passage show --clip example.com
|
||||||
|
Enter master password:
|
||||||
|
Copied example.com to clipboard. Will clear in 10 seconds.
|
||||||
|
|
||||||
|
Create a backup:
|
||||||
|
|
||||||
|
$ passage backup
|
||||||
|
Enter master password:
|
||||||
|
Backup created: passage-backup-20240101-120000.tar.gz
|
||||||
|
Files backed up: 15
|
||||||
|
Checksum: a1b2c3d4e5f6...
|
||||||
|
Checksum file: passage-backup-20240101-120000.tar.gz.sha256
|
||||||
|
|
||||||
|
# SECURITY
|
||||||
|
|
||||||
|
PassAGE uses AGE encryption with Scrypt passphrase encryption for all password files.
|
||||||
|
The master password is verified using Argon2id hashing, which is memory-hard and
|
||||||
|
resistant to brute-force attacks.
|
||||||
|
|
||||||
|
**Important security notes:**
|
||||||
|
|
||||||
|
- The master password is never stored in plaintext
|
||||||
|
- All password files are encrypted individually
|
||||||
|
- File permissions are set to 0600 (files) and 0700 (directories)
|
||||||
|
- Clipboard is automatically cleared after the timeout period
|
||||||
|
- Passwords are cleared from memory when possible
|
||||||
|
|
||||||
|
For detailed security information, see **SECURITY.md** in the PassAGE source code.
|
||||||
|
|
||||||
|
# SEE ALSO
|
||||||
|
|
||||||
|
**age**(1), **git**(1)
|
||||||
|
|
||||||
|
# BUGS
|
||||||
|
|
||||||
|
Report bugs at the project repository
|
||||||
|
|
||||||
|
# AUTHOR
|
||||||
|
|
||||||
|
PassAGE developers
|
||||||
|
|
||||||
|
# COPYRIGHT
|
||||||
|
|
||||||
|
This project uses AGE encryption. See LICENSE file for details.
|
||||||
163
security.go
Normal file
163
security.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkSneakyPaths prevents path traversal attacks
|
||||||
|
// Because users will try to break things, and we'd rather not let them
|
||||||
|
func checkSneakyPaths(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil // Empty is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classic path traversal attempt - catch it early
|
||||||
|
if strings.Contains(path, "..") {
|
||||||
|
return fmt.Errorf("error: path contains '..' - path traversal not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No absolute paths either - keep everything relative
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("error: absolute paths not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path would escape store directory
|
||||||
|
storeDir := getStoreDir()
|
||||||
|
absPath, err := filepath.Abs(filepath.Join(storeDir, path))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error: invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
absStoreDir, err := filepath.Abs(storeDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error: invalid store directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the resolved path is within the store directory
|
||||||
|
relPath, err := filepath.Rel(absStoreDir, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error: path outside store directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(relPath, "..") {
|
||||||
|
return fmt.Errorf("error: path escapes store directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizePath cleans and validates a password path
|
||||||
|
func sanitizePath(path string) (string, error) {
|
||||||
|
if err := checkSneakyPaths(path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the path - normalize separators and such
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Trim slashes - they're not needed
|
||||||
|
cleaned = strings.Trim(cleaned, "/")
|
||||||
|
|
||||||
|
// Reasonable length limit - 4KB should be enough for anyone (famous last words)
|
||||||
|
const maxPathLength = 4096
|
||||||
|
if len(cleaned) > maxPathLength {
|
||||||
|
return "", fmt.Errorf("error: path too long (max %d characters)", maxPathLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxFileSize limits the size of files we'll read into memory
|
||||||
|
// 10MB seems reasonable - if you need bigger passwords, maybe rethink your life choices
|
||||||
|
const maxFileSize = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// checkFileSize checks if a file is within size limits
|
||||||
|
func checkFileSize(path string) error {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() > maxFileSize {
|
||||||
|
return fmt.Errorf("error: file too large (max %d bytes)", maxFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPasswordLength limits password length to prevent DoS
|
||||||
|
// 1MB should be enough for even the most paranoid password
|
||||||
|
const maxPasswordLength = 1024 * 1024
|
||||||
|
|
||||||
|
// checkPasswordLength validates password length
|
||||||
|
func checkPasswordLength(password string) error {
|
||||||
|
if len(password) > maxPasswordLength {
|
||||||
|
return fmt.Errorf("error: password too long (max %d bytes)", maxPasswordLength)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLimited reads up to maxBytes from a reader to prevent resource exhaustion
|
||||||
|
func readLimited(r io.Reader, maxBytes int64) ([]byte, error) {
|
||||||
|
limitedReader := io.LimitReader(r, maxBytes)
|
||||||
|
data, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Check if we hit the limit (would mean file is larger than max)
|
||||||
|
if int64(len(data)) == maxBytes {
|
||||||
|
// Try to read one more byte to see if there's more
|
||||||
|
var buf [1]byte
|
||||||
|
if n, _ := r.Read(buf[:]); n > 0 {
|
||||||
|
return nil, fmt.Errorf("error: file too large (max %d bytes)", maxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSecureTempFile creates a temporary file in a secure location
|
||||||
|
// Tries /dev/shm first (RAM-backed, faster, more secure), falls back to system temp
|
||||||
|
func createSecureTempFile(prefix, suffix string) (string, error) {
|
||||||
|
// Try /dev/shm - it's RAM-backed so it's faster and doesn't touch disk
|
||||||
|
// Not available on all systems, but worth checking
|
||||||
|
if dir := "/dev/shm"; isWritable(dir) {
|
||||||
|
tmpFile, err := os.CreateTemp(dir, prefix)
|
||||||
|
if err == nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
// If it fails, just continue to fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to whatever the OS thinks is a good temp directory
|
||||||
|
tmpFile, err := os.CreateTemp("", prefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWritable checks if a directory is writable
|
||||||
|
func isWritable(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Try to create a test file
|
||||||
|
testFile := filepath.Join(path, ".passage-write-test")
|
||||||
|
f, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(testFile)
|
||||||
|
return true
|
||||||
|
}
|
||||||
346
store.go
Normal file
346
store.go
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultStoreDir = ".passage-store"
|
||||||
|
passageExt = ".passage"
|
||||||
|
masterPassFile = ".master-pass" // Just a hash, not the actual password (obviously)
|
||||||
|
)
|
||||||
|
|
||||||
|
// getStoreDir returns the password store directory path
|
||||||
|
// Tries to be smart about where to put things, but falls back gracefully
|
||||||
|
func getStoreDir() string {
|
||||||
|
if dir := os.Getenv("PASSAGE_DIR"); dir != "" {
|
||||||
|
// Paranoid check for path traversal - you never know what users will try
|
||||||
|
if strings.Contains(dir, "..") {
|
||||||
|
// Nope, not today. Fall back to default.
|
||||||
|
return filepath.Join(os.Getenv("HOME"), defaultStoreDir)
|
||||||
|
}
|
||||||
|
// Make sure we have an absolute path
|
||||||
|
if !filepath.IsAbs(dir) {
|
||||||
|
// Relative paths are fine, just anchor them to HOME
|
||||||
|
return filepath.Join(os.Getenv("HOME"), dir)
|
||||||
|
}
|
||||||
|
// Double-check that even absolute paths don't escape HOME
|
||||||
|
// (because why would you want your passwords outside HOME anyway?)
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home != "" {
|
||||||
|
rel, err := filepath.Rel(home, dir)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
// Nice try, but no. Default it is.
|
||||||
|
return filepath.Join(home, defaultStoreDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
// No custom dir set, use the default location
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
// Can't get home dir? Just use current directory I guess
|
||||||
|
return defaultStoreDir
|
||||||
|
}
|
||||||
|
return filepath.Join(home, defaultStoreDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMasterPassPath returns the path to the master password verification file
|
||||||
|
func getMasterPassPath(dir string) string {
|
||||||
|
return filepath.Join(dir, masterPassFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMasterPassword prompts for the master password and returns SecureBytes
|
||||||
|
// Tries to hide input if possible, but falls back gracefully for pipes/scripts
|
||||||
|
func getMasterPassword(prompt string) (*SecureBytes, error) {
|
||||||
|
if prompt == "" {
|
||||||
|
prompt = "Enter master password: "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to hide the password if we're in a real terminal
|
||||||
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprint(os.Stderr, "\n")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
return NewSecureBytes(password), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a terminal (probably a pipe or script) - can't hide it, but at least it works
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
password, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(password)
|
||||||
|
return NewSecureBytes([]byte(trimmed)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStoreInitialized checks if the store is initialized
|
||||||
|
func checkStoreInitialized(dir string) bool {
|
||||||
|
masterPassPath := getMasterPassPath(dir)
|
||||||
|
_, err := os.Stat(masterPassPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRecipientForEncryption returns a ScryptRecipient for encrypting with master password
|
||||||
|
func getRecipientForEncryption(masterPassword []byte) (age.Recipient, error) {
|
||||||
|
return age.NewScryptRecipient(string(masterPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIdentityForDecryption returns a ScryptIdentity for decrypting with master password
|
||||||
|
func getIdentityForDecryption(masterPassword []byte) (age.Identity, error) {
|
||||||
|
return age.NewScryptIdentity(string(masterPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// hashPassword creates an Argon2id hash of the password for verification
|
||||||
|
// Returns base64-encoded salt:hash
|
||||||
|
func hashPassword(password []byte) (string, error) {
|
||||||
|
// Generate random salt - makes each hash unique even for same password
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argon2id with reasonable defaults:
|
||||||
|
// - 3 iterations (fast enough, slow enough for attackers)
|
||||||
|
// - 32 MB memory (makes GPU attacks expensive)
|
||||||
|
// - 4 threads (parallelism)
|
||||||
|
// - 32 bytes output (standard hash size)
|
||||||
|
hash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
|
||||||
|
|
||||||
|
// Encode as base64: salt:hash
|
||||||
|
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
return saltB64 + ":" + hashB64, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyMasterPassword checks if the provided password matches the stored hash
|
||||||
|
func verifyMasterPassword(storeDir string, password []byte) (bool, error) {
|
||||||
|
masterPassPath := getMasterPassPath(storeDir)
|
||||||
|
data, err := os.ReadFile(masterPassPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
stored := strings.TrimSpace(string(data))
|
||||||
|
|
||||||
|
// Parse stored salt:hash
|
||||||
|
parts := strings.Split(stored, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
// Legacy SHA256 format - try to verify and migrate
|
||||||
|
return verifyLegacySHA256(password, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
saltB64 := parts[0]
|
||||||
|
expectedHashB64 := parts[1]
|
||||||
|
|
||||||
|
// Decode salt and expected hash
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(saltB64)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to decode salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHash, err := base64.RawStdEncoding.DecodeString(expectedHashB64)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to decode hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hash with same parameters
|
||||||
|
computedHash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
return constantTimeCompare(computedHash, expectedHash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyLegacySHA256 verifies against old SHA256 hashes (for migration)
|
||||||
|
// 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