PassAGE/store.go
fraggle 3552db50c2 Initial commit: PassAGE password manager
- AGE encryption with master password model
- Core commands: init, show, insert, edit, generate, rm, mv, cp, find, grep, ls
- Git integration for version control
- Clipboard support (X11 and Wayland)
- Secure password generation
- Backup and restore functionality
- Comprehensive security features
- Complete documentation
2026-01-11 18:48:01 -04:00

347 lines
9.8 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"filippo.io/age"
"golang.org/x/crypto/argon2"
"golang.org/x/term"
)
const (
defaultStoreDir = ".passage-store"
passageExt = ".passage"
masterPassFile = ".master-pass" // Just a hash, not the actual password (obviously)
)
// getStoreDir returns the password store directory path
// Tries to be smart about where to put things, but falls back gracefully
func getStoreDir() string {
if dir := os.Getenv("PASSAGE_DIR"); dir != "" {
// Paranoid check for path traversal - you never know what users will try
if strings.Contains(dir, "..") {
// Nope, not today. Fall back to default.
return filepath.Join(os.Getenv("HOME"), defaultStoreDir)
}
// Make sure we have an absolute path
if !filepath.IsAbs(dir) {
// Relative paths are fine, just anchor them to HOME
return filepath.Join(os.Getenv("HOME"), dir)
}
// Double-check that even absolute paths don't escape HOME
// (because why would you want your passwords outside HOME anyway?)
home := os.Getenv("HOME")
if home != "" {
rel, err := filepath.Rel(home, dir)
if err != nil || strings.HasPrefix(rel, "..") {
// Nice try, but no. Default it is.
return filepath.Join(home, defaultStoreDir)
}
}
return dir
}
// No custom dir set, use the default location
home, err := os.UserHomeDir()
if err != nil {
// Can't get home dir? Just use current directory I guess
return defaultStoreDir
}
return filepath.Join(home, defaultStoreDir)
}
// getMasterPassPath returns the path to the master password verification file
func getMasterPassPath(dir string) string {
return filepath.Join(dir, masterPassFile)
}
// getMasterPassword prompts for the master password and returns SecureBytes
// Tries to hide input if possible, but falls back gracefully for pipes/scripts
func getMasterPassword(prompt string) (*SecureBytes, error) {
if prompt == "" {
prompt = "Enter master password: "
}
// Try to hide the password if we're in a real terminal
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprint(os.Stderr, "\n")
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
return NewSecureBytes(password), nil
}
// Not a terminal (probably a pipe or script) - can't hide it, but at least it works
reader := bufio.NewReader(os.Stdin)
fmt.Fprint(os.Stderr, prompt)
password, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
trimmed := strings.TrimSpace(password)
return NewSecureBytes([]byte(trimmed)), nil
}
// checkStoreInitialized checks if the store is initialized
func checkStoreInitialized(dir string) bool {
masterPassPath := getMasterPassPath(dir)
_, err := os.Stat(masterPassPath)
return err == nil
}
// getRecipientForEncryption returns a ScryptRecipient for encrypting with master password
func getRecipientForEncryption(masterPassword []byte) (age.Recipient, error) {
return age.NewScryptRecipient(string(masterPassword))
}
// getIdentityForDecryption returns a ScryptIdentity for decrypting with master password
func getIdentityForDecryption(masterPassword []byte) (age.Identity, error) {
return age.NewScryptIdentity(string(masterPassword))
}
// hashPassword creates an Argon2id hash of the password for verification
// Returns base64-encoded salt:hash
func hashPassword(password []byte) (string, error) {
// Generate random salt - makes each hash unique even for same password
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
// Argon2id with reasonable defaults:
// - 3 iterations (fast enough, slow enough for attackers)
// - 32 MB memory (makes GPU attacks expensive)
// - 4 threads (parallelism)
// - 32 bytes output (standard hash size)
hash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
// Encode as base64: salt:hash
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
return saltB64 + ":" + hashB64, nil
}
// verifyMasterPassword checks if the provided password matches the stored hash
func verifyMasterPassword(storeDir string, password []byte) (bool, error) {
masterPassPath := getMasterPassPath(storeDir)
data, err := os.ReadFile(masterPassPath)
if err != nil {
return false, err
}
stored := strings.TrimSpace(string(data))
// Parse stored salt:hash
parts := strings.Split(stored, ":")
if len(parts) != 2 {
// Legacy SHA256 format - try to verify and migrate
return verifyLegacySHA256(password, stored)
}
saltB64 := parts[0]
expectedHashB64 := parts[1]
// Decode salt and expected hash
salt, err := base64.RawStdEncoding.DecodeString(saltB64)
if err != nil {
return false, fmt.Errorf("failed to decode salt: %w", err)
}
expectedHash, err := base64.RawStdEncoding.DecodeString(expectedHashB64)
if err != nil {
return false, fmt.Errorf("failed to decode hash: %w", err)
}
// Compute hash with same parameters
computedHash := argon2.IDKey(password, salt, 3, 32*1024, 4, 32)
// Constant-time comparison
return constantTimeCompare(computedHash, expectedHash), nil
}
// verifyLegacySHA256 verifies against old SHA256 hashes (for migration)
// TODO: Maybe remove this eventually? But backwards compat is nice...
func verifyLegacySHA256(password []byte, storedHash string) (bool, error) {
// Old stores used SHA256 - not ideal but we support it for migration
hash := sha256Sum(password)
providedHash := hex.EncodeToString(hash[:])
// Constant-time comparison - can't let attackers time their way in
return constantTimeStringCompare(providedHash, storedHash), nil
}
// sha256Sum computes SHA256 hash (for legacy verification only)
func sha256Sum(data []byte) [32]byte {
return sha256.Sum256(data)
}
// constantTimeCompare performs constant-time comparison of two byte slices
func constantTimeCompare(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// constantTimeStringCompare performs constant-time comparison of two strings
func constantTimeStringCompare(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// storeMasterPasswordHash stores an Argon2id hash of the master password for verification
func storeMasterPasswordHash(storeDir string, password []byte) error {
masterPassPath := getMasterPassPath(storeDir)
hash, err := hashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
return os.WriteFile(masterPassPath, []byte(hash), 0600)
}
// encryptFile encrypts a file using AGE with passphrase encryption
// Uses AGE's scrypt encryption - simple but effective
func encryptFile(inputPath, outputPath string, masterPassword []byte) error {
recipient, err := getRecipientForEncryption(masterPassword)
if err != nil {
return fmt.Errorf("failed to create recipient: %w", err)
}
var input io.Reader
if inputPath == "" || inputPath == "-" {
input = os.Stdin
} else {
file, err := os.Open(inputPath)
if err != nil {
return err
}
defer file.Close()
input = file
}
output, err := os.Create(outputPath)
if err != nil {
return err
}
defer output.Close()
encrypted, err := age.Encrypt(output, recipient)
if err != nil {
return fmt.Errorf("failed to create encrypted writer: %w", err)
}
if _, err := io.Copy(encrypted, input); err != nil {
return fmt.Errorf("failed to encrypt data: %w", err)
}
if err := encrypted.Close(); err != nil {
return fmt.Errorf("failed to finalize encryption: %w", err)
}
return nil
}
// decryptFile decrypts an AGE-encrypted file using master password
// If outputPath is empty or "-", returns a reader for the decrypted content
// If outputPath is specified, writes decrypted content to file and returns nil reader
func decryptFile(inputPath, outputPath string, masterPassword []byte) (io.Reader, error) {
identity, err := getIdentityForDecryption(masterPassword)
if err != nil {
return nil, fmt.Errorf("failed to create identity: %w", err)
}
var input io.Reader
var file *os.File
if inputPath == "" || inputPath == "-" {
input = os.Stdin
} else {
file, err = os.Open(inputPath)
if err != nil {
return nil, err
}
input = file
}
decrypted, err := age.Decrypt(input, identity)
if err != nil {
if file != nil {
file.Close()
}
return nil, fmt.Errorf("failed to decrypt: %w", err)
}
// If output path specified, write to file and close everything
if outputPath != "" && outputPath != "-" {
outputFile, err := os.Create(outputPath)
if err != nil {
if file != nil {
file.Close()
}
return nil, err
}
defer outputFile.Close()
if file != nil {
defer file.Close()
}
if _, err := io.Copy(outputFile, decrypted); err != nil {
return nil, fmt.Errorf("failed to write decrypted data: %w", err)
}
return nil, nil
}
// Return a reader that keeps the file open until reading is complete
// This was a fun bug to track down - file was closing too early!
// Now fileReader handles cleanup properly
if file == nil {
// Reading from stdin - no file to manage
return decrypted, nil
}
return &fileReader{
file: file,
reader: decrypted,
}, nil
}
// fileReader wraps a reader and keeps the underlying file open until reading completes
type fileReader struct {
file *os.File
reader io.Reader
}
func (fr *fileReader) Read(p []byte) (n int, err error) {
if fr == nil || fr.reader == nil {
return 0, io.EOF
}
n, err = fr.reader.Read(p)
if err == io.EOF || err != nil {
// Clean up when we're done - don't leak file handles
if fr.file != nil {
fr.file.Close()
fr.file = nil
}
fr.reader = nil
}
return n, err
}