Compare commits

..

No commits in common. "main" and "fix-memory-security" have entirely different histories.

41 changed files with 488 additions and 2545 deletions

View File

@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(go mod why:*)",
"Bash(go list:*)",
"Bash(~/go/bin/govulncheck -mode=module .)",
"Bash(go test:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(find:*)",
"Bash(make test:*)",
"Bash(go doc:*)",
"Bash(make fmt:*)",
"Bash(make:*)",
"Bash(golangci-lint run:*)",
"Bash(git add:*)",
"Bash(gofumpt:*)",
"Bash(git stash:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(golangci-lint:*)",
"Bash(git checkout:*)",
"Bash(ls:*)",
"WebFetch(domain:golangci-lint.run)",
"Bash(go:*)",
"WebFetch(domain:pkg.go.dev)"
],
"deny": []
}
}

View File

@ -1,21 +0,0 @@
# Build artifacts
secret
coverage.out
*.test
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# macOS
.DS_Store
# Claude files
.claude/
# Local settings
.golangci.yml
.claude/settings.local.json

1
.gitignore vendored
View File

@ -5,4 +5,3 @@
cli.test
vault.test
*.test
settings.local.json

View File

@ -64,14 +64,6 @@ linters-settings:
nlreturn:
block-size: 2
revive:
rules:
- name: var-naming
arguments:
- []
- []
- "upperCaseConst=true"
tagliatelle:
case:
rules:
@ -97,32 +89,3 @@ issues:
- text: "parameter '(args|cmd)' seems to be unused"
linters:
- revive
# Allow ALL_CAPS constant names
- text: "don't use ALL_CAPS in Go names"
linters:
- revive
# Exclude all linters for internal/macse directory
- path: "internal/macse/.*"
linters:
- errcheck
- lll
- mnd
- nestif
- nlreturn
- revive
- unconvert
- govet
- staticcheck
- unused
- ineffassign
- misspell
- gosec
- unparam
- testifylint
- usetesting
- tagliatelle
- nilnil
- intrange
- gochecknoglobals

View File

@ -26,5 +26,3 @@ Read the rules in AGENTS.md and follow them.
* Do not stop working on a task until you have reached the definition of
done provided to you in the initial instruction. Don't do part or most of
the work, do all of the work until the criteria for done are met.
* When you complete each task, if the tests are passing and the code is formatted and there are no linter errors, always commit and push your work. Use a good commit message and don't mention any author or co-author attribution.

View File

@ -1,50 +0,0 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
gcc \
musl-dev \
make \
git
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
gnupg
# Create non-root user
RUN adduser -D -s /bin/sh secret
# Copy binary from builder
COPY --from=builder /build/secret /usr/local/bin/secret
# Ensure binary is executable
RUN chmod +x /usr/local/bin/secret
# Switch to non-root user
USER secret
# Set working directory
WORKDIR /home/secret
# Set entrypoint
ENTRYPOINT ["secret"]

View File

@ -1,23 +1,15 @@
export CGO_ENABLED=1
export DOCKER_HOST := ssh://root@ber1app1.local
# Version information
VERSION := 0.1.0
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
default: check
build: ./secret
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
# Simple build (no code signing needed)
./secret:
go build -v -o $@ cmd/secret/main.go
vet:
go vet ./...
test: lint vet
test:
go test ./... || go test -v ./...
fmt:
@ -26,19 +18,9 @@ fmt:
lint:
golangci-lint run --timeout 5m
check: build test
# Build Docker container
docker:
docker build -t sneak/secret .
# Run Docker container interactively
docker-run:
docker run --rm -it sneak/secret
# Check all code quality (build + vet + lint + unit tests)
check: ./secret vet lint test
# Clean build artifacts
clean:
rm -f ./secret
install: ./secret
cp ./secret $(HOME)/bin/secret

112
README.md
View File

@ -1,12 +1,12 @@
# Secret - Hierarchical Secret Manager
Secret is a command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
Secret is a modern, secure command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
## Core Architecture
### Three-Layer Key Hierarchy
Secret implements a three-layer key architecture:
Secret implements a sophisticated three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
@ -16,7 +16,7 @@ Secret implements a three-layer key architecture:
Each secret maintains a history of versions, with each version having:
- Its own encryption key pair
- Metadata (unencrypted) including creation time and validity period
- Encrypted metadata including creation time and validity period
- Immutable value storage
- Atomic version switching via symlink updates
@ -69,8 +69,8 @@ Initializes the secret manager with a default vault. Prompts for a BIP39 mnemoni
### Vault Management
#### `secret vault list [--json]` / `secret vault ls`
Lists all available vaults. The current vault is marked.
#### `secret vault list [--json]`
Lists all available vaults.
#### `secret vault create <name>`
Creates a new vault with the specified name.
@ -78,12 +78,6 @@ Creates a new vault with the specified name.
#### `secret vault select <name>`
Switches to the specified vault for subsequent operations.
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`, this command does not ask for confirmation.
Requires --force if the vault contains secrets. With --force, will automatically switch to another vault if removing the current one.
- `--force, -f`: Force removal even if vault contains secrets
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
### Secret Management
#### `secret add <secret-name> [--force]`
@ -101,29 +95,14 @@ Retrieves and outputs a secret value to stdout.
#### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring matching.
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
- **NO RECOVERY**: Once removed, the secret cannot be recovered
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
Moves or renames a secret within the current vault.
- Fails if the destination already exists
- Preserves all versions and metadata
### Version Management
#### `secret version list <secret-name>` / `secret version ls`
#### `secret version list <secret-name>`
Lists all versions of a secret showing creation time, status, and validity period.
#### `secret version promote <secret-name> <version>`
Promotes a specific version to current by updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. Like Unix `rm`, this command does not ask for confirmation.
- **NO RECOVERY**: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first)
### Key Generation
#### `secret generate mnemonic`
@ -137,26 +116,21 @@ Generates and stores a random secret.
### Unlocker Management
#### `secret unlocker list [--json]` / `secret unlocker ls`
#### `secret unlockers list [--json]`
Lists all unlockers in the current vault with their metadata.
#### `secret unlocker add <type> [options]`
#### `secret unlockers add <type> [options]`
Creates a new unlocker of the specified type:
**Types:**
- `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only)
**Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
- `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command does not ask for confirmation.
Cannot remove the last unlocker if the vault has secrets unless --force is used.
- `--force, -f`: Force removal of last unlocker even if vault has secrets
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, vault data will be PERMANENTLY INACCESSIBLE
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means losing access to all secrets forever
#### `secret unlockers rm <unlocker-id>`
Removes an unlocker.
#### `secret unlocker select <unlocker-id>`
Selects an unlocker as the current default for operations.
@ -195,7 +169,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001
│ │ │ └── database%password/ # Secret: database/password
@ -233,18 +207,6 @@ Unlockers provide different authentication methods to access the long-term keys:
- Leverages existing key management workflows
- Strong authentication through GPG
3. **Keychain Unlockers** (macOS only):
- Stores unlock keys in macOS Keychain
- Protected by system authentication (Touch ID, password)
- Automatic unlocking when Keychain is unlocked
- Cross-application integration
4. **Secure Enclave Unlockers** (macOS - planned):
- Hardware-backed key storage using Apple Secure Enclave
- Currently partially implemented but non-functional
- Requires Apple Developer Program membership and code signing entitlements
- Full implementation blocked by entitlement requirements
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
#### Secret-specific Keys
@ -279,8 +241,6 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
### Hardware Integration
- Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security
- Secure Enclave support planned (requires Apple Developer Program)
## Examples
@ -299,9 +259,6 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
secret list
secret get database/prod/password
secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret remove ssh/servers/web01
```
### Multi-vault Setup
@ -313,7 +270,7 @@ secret vault create personal
# Work with work vault
secret vault select work
echo "work-db-pass" | secret add database/password
secret unlocker add passphrase # Add passphrase authentication
secret unlockers add passphrase # Add passphrase authentication
# Switch to personal vault
secret vault select personal
@ -321,38 +278,19 @@ echo "personal-email-pass" | secret add email/password
# List all vaults
secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force
```
### Advanced Authentication
```bash
# Add multiple unlock methods
secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
secret unlockers add passphrase # Password-based
secret unlockers add pgp --keyid ABCD1234 # GPG key
# List unlockers
secret unlocker list
secret unlockers list
# Select a specific unlocker
secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
secret unlocker remove <unlocker-id>
```
### Version Management
```bash
# List all versions of a secret
secret version list database/prod/password
# Promote an older version to current
secret version promote database/prod/password 20231215.001
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret version remove database/prod/password 20231214.001
```
### Encryption/Decryption with Age Keys
@ -378,7 +316,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### File Formats
- **Age Files**: Standard Age encryption format (.age extension)
- **Metadata**: Unencrypted JSON format with timestamps and type information
- **Metadata**: JSON format with timestamps and type information
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
### Vault Management
@ -387,8 +325,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
### Cross-Platform Support
- **macOS**: Full support including Keychain and planned Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features)
- **macOS**: Full support including Keychain integration
- **Linux**: Full support (excluding Keychain features)
- **Windows**: Basic support (filesystem operations only)
## Security Considerations
@ -429,19 +367,9 @@ go test -tags=integration -v ./internal/cli # Integration tests
## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers
- **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
# Author
Made with love and lots of expensive SOTA AI by [sneak](https://sneak.berlin) in Berlin in the summer of 2025.
Released as a free software gift to the world, no strings attached, under the [WTFPL](https://www.wtfpl.net/) license.
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)

45
TODO.md
View File

@ -4,51 +4,6 @@ This document outlines the bugs, issues, and improvements that need to be
addressed before the 1.0 release of the secret manager. Items are
prioritized from most critical (top) to least critical (bottom).
## CRITICAL BLOCKERS FOR 1.0 RELEASE
### Command Injection Vulnerabilities
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
### Memory Security Critical Issues
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
### Race Conditions (Data Corruption Risk)
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
### Input Validation Vulnerabilities
- [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding
- [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion
### Timing Attack Vulnerabilities
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
## CRITICAL MEMORY SECURITY ISSUES
### Functions accepting bare []byte for sensitive data
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
### Functions returning unprotected secrets
- [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally
- [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
- [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
- [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
- [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer
### Intermediate string variables for passphrases
- [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter
- [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer
### Unprotected buffer.Bytes() usage
- [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function
- [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes
## Code Cleanups
* we shouldn't be passing around a statedir, it should be read from the

View File

@ -1,102 +0,0 @@
mode: set
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:57.41,60.38 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:60.38,61.41 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:65.2,70.3 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:74.50,76.2 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:79.85,81.28 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:81.28,83.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:86.2,87.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:87.16,89.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:92.2,93.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:93.16,95.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:98.2,98.35 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:102.89,105.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:105.16,107.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:110.2,114.21 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:118.99,119.46 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:119.46,121.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:124.2,134.39 5 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:134.39,137.15 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:137.15,140.4 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:143.3,145.17 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:145.17,147.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.3,150.15 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.15,152.4 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:155.3,156.17 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:156.17,158.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:160.3,160.14 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:163.2,163.17 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:167.107,171.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:171.16,173.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:177.2,186.15 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:187.15,188.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:189.15,190.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:191.15,192.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:193.15,194.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:195.15,196.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:197.10,198.64 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:202.2,204.21 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:208.84,212.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:212.16,214.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:217.2,222.16 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:222.16,224.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:226.2,226.26 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:230.99,234.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:234.16,236.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:239.2,251.45 6 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:251.45,253.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:256.2,275.45 12 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:279.39,284.2 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:287.91,288.36 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:288.36,290.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:292.2,295.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:295.16,297.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:300.2,302.41 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:306.100,307.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:307.32,309.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:311.2,314.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:314.16,316.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:319.2,325.35 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:325.35,327.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:329.2,329.33 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:333.100,334.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:334.32,336.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:338.2,341.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:341.16,343.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:346.2,349.32 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:349.32,351.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:353.2,353.30 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:357.57,375.52 7 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:375.52,381.46 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:381.46,385.4 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:387.3,387.20 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:390.2,390.21 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:394.67,396.2 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:32.22,36.2 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:40.67,41.31 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:41.31,43.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:46.2,55.16 6 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:55.16,57.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:58.2,59.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:59.16,61.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:63.2,63.52 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:68.63,74.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:74.16,76.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:79.2,83.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:83.16,85.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:88.2,91.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:91.16,93.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:95.2,95.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:100.67,103.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:103.16,105.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:108.2,112.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:112.16,114.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:117.2,120.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:120.16,122.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:124.2,124.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:129.77,131.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:131.16,133.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:135.2,135.33 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:140.81,142.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:142.16,144.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:146.2,146.33 1 1

5
go.mod
View File

@ -9,7 +9,6 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
@ -24,11 +23,7 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.33.0 // indirect

13
go.sum
View File

@ -43,10 +43,6 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -67,14 +63,7 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk=
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
@ -128,8 +117,6 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=

View File

@ -2,11 +2,21 @@
package cli
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Global scanner for consistent stdin reading
var stdinScanner *bufio.Scanner //nolint:gochecknoglobals // Needed for consistent stdin handling
// Instance encapsulates all CLI functionality and state
type Instance struct {
fs afero.Fs
@ -57,3 +67,33 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string {
return cli.stateDir
}
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
func getStdinScanner() *bufio.Scanner {
if stdinScanner == nil {
stdinScanner = bufio.NewScanner(os.Stdin)
}
return stdinScanner
}
// readLineFromStdin reads a single line from stdin with a prompt
// Uses a shared scanner to avoid buffering issues between multiple calls
func readLineFromStdin(prompt string) (string, error) {
// Check if stderr is a terminal - if not, we can't prompt interactively
if !term.IsTerminal(syscall.Stderr) {
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
}
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
scanner := getStdinScanner()
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
return "", fmt.Errorf("failed to read from stdin: EOF")
}
return strings.TrimSpace(scanner.Text()), nil
}

View File

@ -1,64 +0,0 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate completion script",
Long: `To load completions:
Bash:
$ source <(secret completion bash)
# To load completions for each session, execute once:
# Linux:
$ secret completion bash > /etc/bash_completion.d/secret
# macOS:
$ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ secret completion zsh > "${fpath[1]}/_secret"
# You will need to start a new shell for this setup to take effect.
Fish:
$ secret completion fish | source
# To load completions for each session, execute once:
$ secret completion fish > ~/.config/fish/completions/secret.fish
PowerShell:
PS> secret completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> secret completion powershell > secret.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell type: %s", args[0])
}
},
}
return cmd
}

View File

@ -1,144 +0,0 @@
package cli
import (
"encoding/json"
"path/filepath"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// getSecretNamesCompletionFunc returns a completion function that provides secret names
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get list of secrets
secrets, err := vlt.ListSecrets()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Filter secrets based on what user has typed
var completions []string
for _, secret := range secrets {
if strings.HasPrefix(secret, toComplete) {
completions = append(completions, secret)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get unlocker metadata list
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
continue
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
// Create the appropriate unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
}
if unlocker != nil {
id := unlocker.GetID()
if strings.HasPrefix(id, toComplete) {
completions = append(completions, id)
}
}
break
}
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getVaultNamesCompletionFunc returns a completion function that provides vault names
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
vaults, err := vault.ListVaults(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []string
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}

View File

@ -96,13 +96,21 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
}
} else {
// Secret exists, get the age secret key from it
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
secretValue, err := cli.getSecretValue(vlt, secretObj)
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
}
defer secretBuffer.Destroy()
ageSecretKey = secretBuffer.String()
// Create secure buffer for the secret value
secureBuffer := memguard.NewBufferFromBytes(secretValue)
defer secureBuffer.Destroy()
// Clear the original secret value
for i := range secretValue {
secretValue[i] = 0
}
ageSecretKey = secureBuffer.String()
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) {
@ -181,28 +189,36 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
}
// Get the age secret key from the secret
var secretBuffer *memguard.LockedBuffer
var secretValue []byte
if os.Getenv(secret.EnvMnemonic) != "" {
secretBuffer, err = secretObj.GetValue(nil)
secretValue, err = secretObj.GetValue(nil)
} else {
unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
}
secretBuffer, err = secretObj.GetValue(unlocker)
secretValue, err = secretObj.GetValue(unlocker)
}
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
}
defer secretBuffer.Destroy()
// Create secure buffer for the secret value
secureBuffer := memguard.NewBufferFromBytes(secretValue)
defer secureBuffer.Destroy()
// Clear the original secret value
for i := range secretValue {
secretValue[i] = 0
}
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(secretBuffer.String()) {
if !isValidAgeSecretKey(secureBuffer.String()) {
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
}
// Parse the age secret key to get the identity
identity, err := age.ParseX25519Identity(secretBuffer.String())
identity, err := age.ParseX25519Identity(secureBuffer.String())
if err != nil {
return fmt.Errorf("failed to parse age secret key: %w", err)
}
@ -250,7 +266,7 @@ func isValidAgeSecretKey(key string) bool {
}
// getSecretValue retrieves the value of a secret using the appropriate unlocker
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil)
}

View File

@ -1,161 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"runtime"
"strings"
"time"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/dustin/go-humanize"
"github.com/fatih/color"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// Version info - these are set at build time
var ( //nolint:gochecknoglobals // Set at build time
Version = "dev" //nolint:gochecknoglobals // Set at build time
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
)
// InfoOutput represents the system information for JSON output
type InfoOutput struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
Author string `json:"author"`
License string `json:"license"`
GoVersion string `json:"goVersion"`
DataDirectory string `json:"dataDirectory"`
CurrentVault string `json:"currentVault"`
NumVaults int `json:"numVaults"`
NumSecrets int `json:"numSecrets"`
TotalSize int64 `json:"totalSizeBytes"`
OldestSecret time.Time `json:"oldestSecret,omitempty"`
LatestSecret time.Time `json:"latestSecret,omitempty"`
}
// newInfoCmd returns the info command
func newInfoCmd() *cobra.Command {
cli := NewCLIInstance()
var jsonOutput bool
cmd := &cobra.Command{
Use: "info",
Short: "Display system information",
Long: "Display information about the secret system including version, vault statistics, and storage usage",
RunE: func(cmd *cobra.Command, _ []string) error {
return cli.Info(cmd, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
return cmd
}
// Info displays system information
func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
info := InfoOutput{
Version: Version,
GitCommit: GitCommit,
Author: "Jeffrey Paul <sneak@sneak.berlin>",
License: "WTFPL",
GoVersion: runtime.Version(),
DataDirectory: cli.stateDir,
}
// Get current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err == nil {
info.CurrentVault = currentVault.Name
}
// Count vaults
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
if err == nil {
for _, entry := range vaultEntries {
if entry.IsDir() {
info.NumVaults++
}
}
}
// Gather statistics from all vaults
if info.NumVaults > 0 {
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(cli.fs, vaultsDir)
info.NumSecrets = totalSecrets
info.TotalSize = totalSize
if !oldestTime.IsZero() {
info.OldestSecret = oldestTime
}
if !latestTime.IsZero() {
info.LatestSecret = latestTime
}
}
if jsonOutput {
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
return encoder.Encode(info)
}
// Pretty print with colors and emoji
return prettyPrintInfo(cmd.OutOrStdout(), info)
}
// prettyPrintInfo formats and prints the info in a pretty format
func prettyPrintInfo(w io.Writer, info InfoOutput) error {
const separatorLength = 40
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
yellow := color.New(color.FgYellow)
magenta := color.New(color.FgMagenta)
_, _ = fmt.Fprintln(w)
_, _ = bold.Fprintln(w, "🔐 Secret System Information")
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
_, _ = fmt.Fprintf(w, "📦 Version: %s\n", green.Sprint(info.Version))
_, _ = fmt.Fprintf(w, "🔧 Git Commit: %s\n", cyan.Sprint(info.GitCommit))
_, _ = fmt.Fprintf(w, "👤 Author: %s\n", cyan.Sprint(info.Author))
_, _ = fmt.Fprintf(w, "📜 License: %s\n", cyan.Sprint(info.License))
_, _ = fmt.Fprintf(w, "🐹 Go Version: %s\n", cyan.Sprint(info.GoVersion))
_, _ = fmt.Fprintf(w, "📁 Data Directory: %s\n", yellow.Sprint(info.DataDirectory))
if info.CurrentVault != "" {
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", magenta.Sprint(info.CurrentVault))
} else {
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", color.RedString("(none)"))
}
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
if info.TotalSize >= 0 {
//nolint:gosec // TotalSize is always >= 0
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
} else {
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
}
if !info.OldestSecret.IsZero() {
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n", info.OldestSecret.Format("2006-01-02 15:04:05"))
}
if !info.LatestSecret.IsZero() {
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
}
_, _ = fmt.Fprintln(w)
return nil
}

View File

@ -1,83 +0,0 @@
package cli
import (
"path/filepath"
"time"
"github.com/spf13/afero"
)
// gatherVaultStats collects statistics from all vaults
func gatherVaultStats(
fs afero.Fs,
vaultsDir string,
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) {
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return 0, 0, time.Time{}, time.Time{}, err
}
for _, vaultEntry := range vaultEntries {
if !vaultEntry.IsDir() {
continue
}
vaultPath := filepath.Join(vaultsDir, vaultEntry.Name())
secretsPath := filepath.Join(vaultPath, "secrets.d")
// Count secrets in this vault
secretEntries, err := afero.ReadDir(fs, secretsPath)
if err != nil {
continue
}
for _, secretEntry := range secretEntries {
if !secretEntry.IsDir() {
continue
}
totalSecrets++
secretPath := filepath.Join(secretsPath, secretEntry.Name())
// Get size and timestamps from all versions
versionsPath := filepath.Join(secretPath, "versions")
versionEntries, err := afero.ReadDir(fs, versionsPath)
if err != nil {
continue
}
for _, versionEntry := range versionEntries {
if !versionEntry.IsDir() {
continue
}
versionPath := filepath.Join(versionsPath, versionEntry.Name())
// Add size of encrypted data
dataPath := filepath.Join(versionPath, "data.age")
if stat, err := fs.Stat(dataPath); err == nil {
totalSize += stat.Size()
}
// Add size of metadata
metaPath := filepath.Join(versionPath, "metadata.age")
if stat, err := fs.Stat(metaPath); err == nil {
totalSize += stat.Size()
}
// Track timestamps
if stat, err := fs.Stat(versionPath); err == nil {
modTime := stat.ModTime()
if oldestTime.IsZero() || modTime.Before(oldestTime) {
oldestTime = modTime
}
if latestTime.IsZero() || modTime.After(latestTime) {
latestTime = modTime
}
}
}
}
}
return totalSecrets, totalSize, oldestTime, latestTime, nil
}

View File

@ -60,17 +60,14 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
// Read mnemonic from stdin using shared line reader
var err error
mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
}
if mnemonicStr == "" {
@ -205,26 +202,20 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
if err != nil {
return nil, err
}
defer passphraseBuffer1.Destroy()
// Read confirmation passphrase
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
if err != nil {
passphraseBuffer1.Destroy()
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
}
defer passphraseBuffer2.Destroy()
// Compare passphrases
if passphraseBuffer1.String() != passphraseBuffer2.String() {
passphraseBuffer1.Destroy()
passphraseBuffer2.Destroy()
return nil, fmt.Errorf("passphrases do not match")
}
// Clean up the second buffer, we'll return the first
passphraseBuffer2.Destroy()
// Return the first buffer (caller is responsible for destroying it)
return passphraseBuffer1, nil
// Create a new buffer with the confirmed passphrase
return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
}

View File

@ -18,11 +18,6 @@ import (
"github.com/stretchr/testify/require"
)
const (
// testMnemonic is a standard BIP39 mnemonic used for testing
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
// TestMain runs before all tests and ensures the binary is built
func TestMain(m *testing.M) {
// Get the current working directory
@ -65,6 +60,7 @@ func TestSecretManagerIntegration(t *testing.T) {
}
// Test configuration
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testPassphrase := "test-passphrase-123"
// Create a temporary directory for our vault
@ -129,8 +125,7 @@ func TestSecretManagerIntegration(t *testing.T) {
// - work vault has pub.age file
// - work vault has unlockers.d/passphrase directory
// - Unlocker metadata and encrypted keys present
// NOTE: Skipped because vault creation now includes mnemonic import
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// Test 5: Add secrets with versioning
// Command: echo "password123" | secret add database/password
@ -182,26 +177,14 @@ func TestSecretManagerIntegration(t *testing.T) {
// Expected: Shows database/password with metadata
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
// Test 11b: List secrets with quiet flag
// Command: secret list -q
// Purpose: Test quiet output for scripting
// Expected: Only secret names, no headers or formatting
test11bListSecretsQuiet(t, testMnemonic, runSecret)
// Test 12: Add secrets with different name formats
// Commands: Various secret names (paths, dots, underscores)
// Purpose: Test secret name validation and storage encoding
// Expected: Proper filesystem encoding (/ -> %)
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
// Test 12b: Move/rename secrets
// Commands: secret move, secret mv, secret rename
// Purpose: Test moving and renaming secrets
// Expected: Secret moved to new location, old location removed
test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin)
// Test 13: Unlocker management
// Commands: secret unlocker list, secret unlocker add pgp
// Commands: secret unlockers list, secret unlockers add pgp
// Purpose: Test multiple unlocker types
// Expected filesystem:
// - Multiple directories under unlockers.d/
@ -457,12 +440,6 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
}
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
// Set environment variables for vault creation
os.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase")
defer os.Unsetenv("SB_SECRET_MNEMONIC")
defer os.Unsetenv("SB_UNLOCK_PASSPHRASE")
// Create work vault
output, err := runSecret("vault", "create", "work")
require.NoError(t, err, "vault create should succeed")
@ -491,9 +468,9 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
secretsDir := filepath.Join(workVaultDir, "secrets.d")
verifyFileExists(t, secretsDir)
// Verify that work vault has a long-term key (mnemonic was provided)
// Verify that work vault does NOT have a long-term key yet (no mnemonic imported)
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
verifyFileExists(t, pubKeyFile)
verifyFileNotExists(t, pubKeyFile)
// List vaults to verify both exist
output, err = runSecret("vault", "list")
@ -924,81 +901,6 @@ func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...stri
assert.True(t, secretNames["database/password"], "should have database/password")
}
func test11bListSecretsQuiet(t *testing.T, testMnemonic string, runSecret func(...string) (string, error)) {
// Test quiet output
quietOutput, err := runSecret("list", "-q")
require.NoError(t, err, "secret list -q should succeed")
// Split output into lines
lines := strings.Split(strings.TrimSpace(quietOutput), "\n")
// Should have exactly 3 lines (3 secrets)
assert.Len(t, lines, 3, "quiet output should have exactly 3 lines")
// Should not contain any headers or formatting
assert.NotContains(t, quietOutput, "Secrets in vault", "should not have vault header")
assert.NotContains(t, quietOutput, "NAME", "should not have NAME header")
assert.NotContains(t, quietOutput, "LAST UPDATED", "should not have LAST UPDATED header")
assert.NotContains(t, quietOutput, "Total:", "should not have total count")
assert.NotContains(t, quietOutput, "----", "should not have separator lines")
// Should contain exactly the secret names
secretNames := make(map[string]bool)
for _, line := range lines {
secretNames[line] = true
}
assert.True(t, secretNames["api/key"], "should have api/key")
assert.True(t, secretNames["config/database.yaml"], "should have config/database.yaml")
assert.True(t, secretNames["database/password"], "should have database/password")
// Test quiet output with filter
quietFilterOutput, err := runSecret("list", "database", "-q")
require.NoError(t, err, "secret list with filter and -q should succeed")
// Should only show secrets matching filter
filteredLines := strings.Split(strings.TrimSpace(quietFilterOutput), "\n")
assert.Len(t, filteredLines, 2, "quiet filtered output should have exactly 2 lines")
// Verify filtered results
filteredSecrets := make(map[string]bool)
for _, line := range filteredLines {
filteredSecrets[line] = true
}
assert.True(t, filteredSecrets["config/database.yaml"], "should have config/database.yaml")
assert.True(t, filteredSecrets["database/password"], "should have database/password")
assert.False(t, filteredSecrets["api/key"], "should not have api/key")
// Test that quiet and JSON flags are mutually exclusive behavior
// (JSON should take precedence if both are specified)
jsonQuietOutput, err := runSecret("list", "--json", "-q")
require.NoError(t, err, "secret list --json -q should succeed")
// Should be valid JSON, not quiet output
var jsonResponse map[string]interface{}
err = json.Unmarshal([]byte(jsonQuietOutput), &jsonResponse)
assert.NoError(t, err, "output should be valid JSON when both flags are used")
// Test using quiet output in command substitution would work like:
// secret get $(secret list -q | head -1)
// We'll simulate this by getting the first secret name
firstSecret := lines[0]
// Need to create a runSecretWithEnv to provide mnemonic for get operation
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
return cli.ExecuteCommandInProcess(args, "", env)
}
getOutput, err := runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", firstSecret)
require.NoError(t, err, "get with secret name from quiet output should succeed")
// Verify we got a value (not empty)
assert.NotEmpty(t, getOutput, "should retrieve a non-empty secret value")
}
func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
// Make sure we're in default vault
runSecret := func(args ...string) (string, error) {
@ -1108,89 +1010,15 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
}
}
func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
// First, create a secret to move
_, err := runSecretWithStdin("original-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/original")
require.NoError(t, err, "add test/original should succeed")
// Test move command
output, err := runSecret("move", "test/original", "test/renamed")
require.NoError(t, err, "move should succeed")
assert.Contains(t, output, "Moved secret 'test/original' to 'test/renamed'", "should show move confirmation")
// Need to create a runSecretWithEnv for get operations
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
return cli.ExecuteCommandInProcess(args, "", env)
}
// Verify original doesn't exist
_, err = runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/original")
assert.Error(t, err, "get original should fail after move")
// Verify new location exists and has correct value
getOutput, err := runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/renamed")
require.NoError(t, err, "get renamed should succeed")
assert.Equal(t, "original-value", getOutput, "renamed secret should have original value")
// Test mv alias
_, err = runSecretWithStdin("another-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/another")
require.NoError(t, err, "add test/another should succeed")
output, err = runSecret("mv", "test/another", "test/moved-with-mv")
require.NoError(t, err, "mv alias should work")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Test rename alias
_, err = runSecretWithStdin("rename-test-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/rename-me")
require.NoError(t, err, "add test/rename-me should succeed")
output, err = runSecret("rename", "test/rename-me", "test/renamed-with-alias")
require.NoError(t, err, "rename alias should work")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Test error cases
// Try to move non-existent secret
output, err = runSecret("move", "test/nonexistent", "test/destination")
assert.Error(t, err, "move non-existent should fail")
assert.Contains(t, output, "not found", "should indicate source not found")
// Try to move to existing destination
_, err = runSecretWithStdin("dest-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/existing-dest")
require.NoError(t, err, "add test/existing-dest should succeed")
output, err = runSecret("move", "test/renamed", "test/existing-dest")
assert.Error(t, err, "move to existing destination should fail")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Verify the source wasn't removed since move failed
getOutput, err = runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/renamed")
require.NoError(t, err, "get source should still work after failed move")
assert.Equal(t, "original-value", getOutput, "source should still have original value")
}
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
// Make sure we're in default vault
_, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed")
// List unlockers
output, err := runSecret("unlocker", "list")
require.NoError(t, err, "unlocker list should succeed")
t.Logf("DEBUG: unlocker list output: %q", output)
output, err := runSecret("unlockers", "list")
require.NoError(t, err, "unlockers list should succeed")
t.Logf("DEBUG: unlockers list output: %q", output)
// Should have the passphrase unlocker created during init
assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
@ -1199,15 +1027,15 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
output, err = runSecretWithEnv(map[string]string{
"SB_UNLOCK_PASSPHRASE": "another-passphrase",
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
}, "unlocker", "add", "passphrase")
}, "unlockers", "add", "passphrase")
if err != nil {
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
}
require.NoError(t, err, "add passphrase unlocker should succeed")
// List unlockers again - should have 2 now
output, err = runSecret("unlocker", "list")
require.NoError(t, err, "unlocker list should succeed")
output, err = runSecret("unlockers", "list")
require.NoError(t, err, "unlockers list should succeed")
// Count passphrase unlockers
lines := strings.Split(output, "\n")
@ -1223,8 +1051,8 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
// Test JSON output
jsonOutput, err := runSecret("unlocker", "list", "--json")
require.NoError(t, err, "unlocker list --json should succeed")
jsonOutput, err := runSecret("unlockers", "list", "--json")
require.NoError(t, err, "unlockers list --json should succeed")
var response map[string]interface{}
err = json.Unmarshal([]byte(jsonOutput), &response)
@ -1708,10 +1536,10 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
// Test secret list --json (already tested in test 11)
// Test unlocker list --json (already tested in test 13)
// Test unlockers list --json (already tested in test 13)
// All JSON outputs verified to be valid and contain expected fields
t.Log("JSON output formats verified for vault list, secret list, and unlocker list")
t.Log("JSON output formats verified for vault list, secret list, and unlockers list")
}
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {

View File

@ -34,15 +34,12 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd())
cmd.AddCommand(newRemoveCmd())
cmd.AddCommand(newMoveCmd())
cmd.AddCommand(newUnlockersCmd())
cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd())
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newInfoCmd())
cmd.AddCommand(newCompletionCmd())
secret.Debug("newRootCmd completed")

View File

@ -4,13 +4,11 @@ import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@ -39,12 +37,10 @@ func newAddCmd() *cobra.Command {
}
func newGetCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "get <secret-name>",
Short: "Retrieve a secret from the vault",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
version, _ := cmd.Flags().GetString("version")
cli := NewCLIInstance()
@ -67,7 +63,6 @@ func newListCmd() *cobra.Command {
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
quietOutput, _ := cmd.Flags().GetBool("quiet")
var filter string
if len(args) > 0 {
@ -76,12 +71,11 @@ func newListCmd() *cobra.Command {
cli := NewCLIInstance()
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
return cli.ListSecrets(cmd, jsonOutput, filter)
},
}
cmd.Flags().Bool("json", false, "Output in JSON format")
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
return cmd
}
@ -109,53 +103,6 @@ func newImportCmd() *cobra.Command {
return cmd
}
func newRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <secret-name>",
Aliases: []string{"rm"},
Short: "Remove a secret from the vault",
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
`cannot be undone.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.RemoveSecret(cmd, args[0], false)
},
}
return cmd
}
func newMoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "move <source> <destination>",
Aliases: []string{"mv", "rename"},
Short: "Move or rename a secret",
Long: `Move or rename a secret within the current vault. ` +
`If the destination already exists, the operation will fail.`,
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Only complete the first argument (source)
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.MoveSecret(cmd, args[0], args[1])
},
}
return cmd
}
// updateBufferSize updates the buffer size based on usage pattern
func updateBufferSize(currentSize int, sameSize *int) int {
*sameSize++
@ -317,7 +264,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
}
// ListSecrets lists all secrets in the current vault
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@ -373,21 +320,15 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
return fmt.Errorf("failed to marshal JSON: %w", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
} else if quietOutput {
// Quiet output - just secret names
for _, secretName := range filteredSecrets {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
}
cmd.Println(string(jsonBytes))
} else {
// Pretty table output
out := cmd.OutOrStdout()
if len(filteredSecrets) == 0 {
if filter != "" {
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
} else {
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
cmd.Println("No secrets found in current vault.")
cmd.Println("Run 'secret add <name>' to create one.")
}
return nil
@ -395,25 +336,12 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
// Get current vault name for display
if filter != "" {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
} else {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
}
// Calculate the maximum name length for proper column alignment
maxNameLen := len("NAME") // Start with header length
for _, secretName := range filteredSecrets {
if len(secretName) > maxNameLen {
maxNameLen = len(secretName)
}
}
// Add some padding
maxNameLen += 2
// Print headers with dynamic width
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------")
cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
cmd.Printf("%-40s %-20s\n", "----", "------------")
for _, secretName := range filteredSecrets {
lastUpdated := "unknown"
@ -421,14 +349,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
}
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
cmd.Printf("%-40s %-20s\n", secretName, lastUpdated)
}
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
if filter != "" {
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
cmd.Printf(" (filtered from %d)", len(secrets))
}
_, _ = fmt.Fprintln(out)
cmd.Println()
}
return nil
@ -520,93 +448,3 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
return nil
}
// RemoveSecret removes a secret from the vault
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
// Get current vault
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
// Check if secret exists
vaultDir, err := currentVlt.GetDirectory()
if err != nil {
return err
}
encodedName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret '%s' not found", secretName)
}
// Count versions for information
versionsDir := filepath.Join(secretDir, "versions")
versionCount := 0
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
versionCount = len(entries)
}
// Remove the secret directory
if err := cli.fs.RemoveAll(secretDir); err != nil {
return fmt.Errorf("failed to remove secret: %w", err)
}
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
return nil
}
// MoveSecret moves or renames a secret
func (cli *Instance) MoveSecret(cmd *cobra.Command, sourceName, destName string) error {
// Get current vault
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
// Get vault directory
vaultDir, err := currentVlt.GetDirectory()
if err != nil {
return err
}
// Check if source exists
sourceEncoded := strings.ReplaceAll(sourceName, "/", "%")
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
exists, err := afero.DirExists(cli.fs, sourceDir)
if err != nil {
return fmt.Errorf("failed to check if source secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret '%s' not found", sourceName)
}
// Check if destination already exists
destEncoded := strings.ReplaceAll(destName, "/", "%")
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
exists, err = afero.DirExists(cli.fs, destDir)
if err != nil {
return fmt.Errorf("failed to check if destination secret exists: %w", err)
}
if exists {
return fmt.Errorf("secret '%s' already exists", destName)
}
// Perform the move
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
return fmt.Errorf("failed to move secret: %w", err)
}
cmd.Printf("Moved secret '%s' to '%s'\n", sourceName, destName)
return nil
}

View File

@ -4,9 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@ -17,81 +15,27 @@ import (
"github.com/spf13/cobra"
)
// UnlockerInfo represents unlocker information for display
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
IsCurrent bool `json:"isCurrent"`
}
// Import from init.go
// Table formatting constants
const (
unlockerIDWidth = 40
unlockerTypeWidth = 12
unlockerDateWidth = 20
unlockerFlagsWidth = 20
)
// ... existing imports ...
// getDefaultGPGKey returns the default GPG key ID if available
func getDefaultGPGKey() (string, error) {
// First try to get the configured default key using gpgconf
cmd := exec.Command("gpgconf", "--list-options", "gpg")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
for _, line := range lines {
fields := strings.Split(line, ":")
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
// The default key is in field 10 (index 9)
return fields[9], nil
}
}
}
// If no default key is configured, get the first secret key
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
output, err = cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list GPG keys: %w", err)
}
// Parse output to find the first usable secret key
lines := strings.Split(string(output), "\n")
for _, line := range lines {
// sec line indicates a secret key
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
// Field 5 contains the key ID
if len(fields) > 4 && fields[4] != "" {
return fields[4], nil
}
}
}
return "", fmt.Errorf("no GPG secret keys found")
}
func newUnlockerCmd() *cobra.Command {
func newUnlockersCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "unlocker",
Use: "unlockers",
Short: "Manage unlockers",
Long: `Create, list, and remove unlockers for the current vault.`,
}
cmd.AddCommand(newUnlockerListCmd())
cmd.AddCommand(newUnlockerAddCmd())
cmd.AddCommand(newUnlockerRemoveCmd())
cmd.AddCommand(newUnlockerSelectCmd())
cmd.AddCommand(newUnlockersListCmd())
cmd.AddCommand(newUnlockersAddCmd())
cmd.AddCommand(newUnlockersRmCmd())
return cmd
}
func newUnlockerListCmd() *cobra.Command {
func newUnlockersListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
@ -108,115 +52,54 @@ func newUnlockerListCmd() *cobra.Command {
return cmd
}
func newUnlockerAddCmd() *cobra.Command {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
typeDescriptions := `Available unlocker types:
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
typeDescriptions = `Available unlocker types:
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
keychain - macOS Keychain integration (macOS only)
Stores the vault's master key in the macOS Keychain, protected by your login password.
Automatically unlocks when your Keychain is unlocked (e.g., after login).
Provides seamless integration with macOS security features like Touch ID.
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
}
func newUnlockersAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <type>",
Short: "Add a new unlocker",
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
%s
Each vault can have multiple unlockers, allowing different authentication methods
to access the same vault. This provides flexibility and backup access options.`, typeDescriptions),
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
unlockerType := args[0]
// Validate unlocker type
validTypes := strings.Split(supportedTypes, ", ")
valid := false
for _, t := range validTypes {
if unlockerType == t {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
}
// Check if --keyid was used with non-PGP type
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
}
return cli.UnlockersAdd(unlockerType, cmd)
return cli.UnlockersAdd(args[0], cmd)
},
}
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
return cmd
}
func newUnlockerRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
func newUnlockersRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <unlocker-id>",
Short: "Remove an unlocker",
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
`will be permanently inaccessible.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.UnlockersRemove(args[0], force, cmd)
return cli.UnlockersRemove(args[0])
},
}
}
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "unlocker",
Short: "Manage current unlocker",
Long: `Select the current unlocker for operations.`,
}
cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd
}
func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
func newUnlockerSelectSubCmd() *cobra.Command {
return &cobra.Command{
Use: "select <unlocker-id>",
Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -233,13 +116,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
return err
}
// Get the current unlocker ID
var currentUnlockerID string
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err == nil {
currentUnlockerID = currentUnlocker.GetID()
}
// Get the metadata first
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
@ -247,6 +123,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
}
// Load actual unlocker objects to get the proper IDs
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
}
var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList {
// Create unlocker instance to get the proper ID
@ -312,23 +195,14 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
Type: metadata.Type,
CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID,
}
unlockers = append(unlockers, unlockerInfo)
}
if jsonOutput {
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
}
return cli.printUnlockersTable(unlockers)
}
// printUnlockersJSON prints unlockers in JSON format
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
// JSON output
output := map[string]interface{}{
"unlockers": unlockers,
"currentUnlockerID": currentUnlockerID,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
@ -337,35 +211,24 @@ func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlocke
}
cli.cmd.Println(string(jsonBytes))
return nil
}
// printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
} else {
// Pretty table output
if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.")
return nil
}
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
prefix := " "
if unlocker.IsCurrent {
prefix = "* "
}
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
prefix,
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
@ -373,18 +236,13 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
}
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
}
return nil
}
// UnlockersAdd adds a new unlocker
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
}
switch unlockerType {
case "passphrase":
// Get current vault
@ -416,20 +274,9 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "keychain":
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS")
}
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
@ -440,52 +287,17 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Keychain Item Name: %s\n", keyName)
}
// Auto-select the newly created unlocker
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "pgp":
// Get GPG key ID from flag, environment, or default key
// Get GPG key ID from flag or environment variable
var gpgKeyID string
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
gpgKeyID = flagKeyID
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
gpgKeyID = envKeyID
} else {
// Try to get the default GPG key
defaultKeyID, err := getDefaultGPGKey()
if err != nil {
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
}
gpgKeyID = defaultKeyID
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
}
// Check if this key is already added as an unlocker
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Resolve the GPG key ID to its fingerprint
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
// Check if this GPG key is already added
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
}
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
@ -496,63 +308,22 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
// Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
default:
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
}
}
// UnlockersRemove removes an unlocker with safety checks
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
// UnlockersRemove removes an unlocker
func (cli *Instance) UnlockersRemove(unlockerID string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
// Get list of unlockers
unlockers, err := vlt.ListUnlockers()
if err != nil {
return fmt.Errorf("failed to list unlockers: %w", err)
}
// Check if we're removing the last unlocker
if len(unlockers) == 1 {
// Check if vault has secrets
numSecrets, err := vlt.NumSecrets()
if err != nil {
return fmt.Errorf("failed to count secrets: %w", err)
}
if numSecrets > 0 && !force {
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
cmd.Println("If you want to proceed anyway, use --force")
return fmt.Errorf("refusing to remove last unlocker")
}
if numSecrets > 0 && force {
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
}
}
// Remove the unlocker
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
return err
}
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
return nil
return vlt.RemoveUnlocker(unlockerID)
}
// UnlockerSelect selects an unlocker as current
@ -565,69 +336,3 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID)
}
// checkUnlockerExists checks if an unlocker with the given ID exists
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
// Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers()
if err != nil {
return nil // If we can't list unlockers, assume it doesn't exist
}
// Get vault directory to construct unlocker instances
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil
}
// Check each unlocker's ID
for _, metadata := range unlockers {
// Construct the unlocker based on type to get its ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
continue
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this matches our metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
var unlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
}
if unlocker != nil && unlocker.GetID() == unlockerID {
return fmt.Errorf("unlocker already exists")
}
break
}
}
}
return nil
}

View File

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@ -28,7 +27,6 @@ func newVaultCmd() *cobra.Command {
cmd.AddCommand(newVaultCreateCmd())
cmd.AddCommand(newVaultSelectCmd())
cmd.AddCommand(newVaultImportCmd())
cmd.AddCommand(newVaultRemoveCmd())
return cmd
}
@ -36,7 +34,6 @@ func newVaultCmd() *cobra.Command {
func newVaultListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List available vaults",
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
@ -66,13 +63,10 @@ func newVaultCreateCmd() *cobra.Command {
}
func newVaultSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{
Use: "select <name>",
Short: "Select a vault as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -82,14 +76,11 @@ func newVaultSelectCmd() *cobra.Command {
}
func newVaultImportCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{
Use: "import <vault-name>",
Short: "Import a mnemonic into a vault",
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
vaultName := "default"
if len(args) > 0 {
@ -103,29 +94,6 @@ func newVaultImportCmd() *cobra.Command {
}
}
func newVaultRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
Short: "Remove a vault",
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
`switch to another vault if removing the currently selected one.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
return cli.RemoveVault(cmd, args[0], force)
},
}
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
return cmd
}
// ListVaults lists all available vaults
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
@ -179,95 +147,12 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
// Get or prompt for mnemonic
var mnemonicStr string
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
secret.Debug("Using mnemonic from environment variable")
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
}
if mnemonicStr == "" {
return fmt.Errorf("mnemonic cannot be empty")
}
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonicStr)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonicStr) {
return fmt.Errorf("invalid BIP39 mnemonic phrase")
}
// Set mnemonic in environment for CreateVault to use
originalMnemonic := os.Getenv(secret.EnvMnemonic)
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
defer func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}()
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
if err != nil {
return err
}
// Get the vault metadata to retrieve the derivation index
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
if err != nil {
return fmt.Errorf("failed to load vault metadata: %w", err)
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
if err != nil {
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Get or prompt for passphrase
var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable")
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else {
secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err)
}
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
return fmt.Errorf("failed to create unlocker: %w", err)
}
cmd.Printf("Created vault '%s'\n", vlt.GetName())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil
}
@ -410,90 +295,3 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
return nil
}
// RemoveVault removes a vault with safety checks
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to list vaults: %w", err)
}
// Check if vault exists
vaultExists := false
for _, v := range vaults {
if v == name {
vaultExists = true
break
}
}
if !vaultExists {
return fmt.Errorf("vault '%s' does not exist", name)
}
// Don't allow removing the last vault
if len(vaults) == 1 {
return fmt.Errorf("cannot remove the last vault")
}
// Check if this is the current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
isCurrentVault := currentVault.GetName() == name
// Load the vault to check for secrets
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Check if vault has secrets
secretsDir := filepath.Join(vaultDir, "secrets.d")
hasSecrets := false
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
entries, err := afero.ReadDir(cli.fs, secretsDir)
if err == nil && len(entries) > 0 {
hasSecrets = true
}
}
// Require --force if vault has secrets
if hasSecrets && !force {
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
}
// If removing current vault, switch to another vault first
if isCurrentVault {
// Find another vault to switch to
var newVault string
for _, v := range vaults {
if v != name {
newVault = v
break
}
}
// Switch to the new vault
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
}
cmd.Printf("Switched current vault to '%s'\n", newVault)
}
// Remove the vault directory
if err := cli.fs.RemoveAll(vaultDir); err != nil {
return fmt.Errorf("failed to remove vault directory: %w", err)
}
cmd.Printf("Removed vault '%s'\n", name)
if hasSecrets {
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
}
return nil
}

View File

@ -34,10 +34,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
// List versions command
listCmd := &cobra.Command{
Use: "list <secret-name>",
Aliases: []string{"ls"},
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.ListVersions(cmd, args[0])
},
@ -49,40 +47,12 @@ func VersionCommands(cli *Instance) *cobra.Command {
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// TODO: Complete version numbers for second arg
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(cmd, args[0], args[1])
},
}
// Remove version command
removeCmd := &cobra.Command{
Use: "remove <secret-name> <version>",
Aliases: []string{"rm"},
Short: "Remove a specific version of a secret",
Long: "Remove a specific version of a secret. Cannot remove the current version.",
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// TODO: Complete version numbers for second arg
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
return cli.RemoveVersion(cmd, args[0], args[1])
},
}
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
versionCmd.AddCommand(listCmd, promoteCmd)
return versionCmd
}
@ -237,60 +207,3 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
return nil
}
// RemoveVersion removes a specific version of a secret
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return err
}
// Get the encoded secret name
encodedName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret '%s' not found", secretName)
}
// Check if version exists
versionDir := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(cli.fs, versionDir)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
}
// Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to get current version: %w", err)
}
// Don't allow removing the current version
if version == currentVersion {
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
}
// Remove the version directory
if err := cli.fs.RemoveAll(versionDir); err != nil {
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
}

View File

@ -54,7 +54,7 @@ func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([
}
// DecryptWithIdentity decrypts data with an identity using age
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(data), identity)
if err != nil {
return nil, fmt.Errorf("failed to create decryptor: %w", err)
@ -65,40 +65,40 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
}
// Create a secure buffer for the decrypted data
resultBuffer := memguard.NewBufferFromBytes(result)
return resultBuffer, nil
return result, nil
}
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
if data == nil {
return nil, fmt.Errorf("data buffer is nil")
}
// The passphrase parameter should be a LockedBuffer for secure memory handling
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil")
}
// Create recipient directly from passphrase - unavoidable string conversion due to age API
recipient, err := age.NewScryptRecipient(passphrase.String())
// Get the passphrase string temporarily
passphraseStr := passphrase.String()
recipient, err := age.NewScryptRecipient(passphraseStr)
if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
}
return EncryptToRecipient(data, recipient)
// Create a secure buffer for the data
dataBuffer := memguard.NewBufferFromBytes(data)
defer dataBuffer.Destroy()
return EncryptToRecipient(dataBuffer, recipient)
}
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
// The passphrase parameter should be a LockedBuffer for secure memory handling
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil")
}
// Create identity directly from passphrase - unavoidable string conversion due to age API
identity, err := age.NewScryptIdentity(passphrase.String())
// Get the passphrase string temporarily
passphraseStr := passphrase.String()
identity, err := age.NewScryptIdentity(passphraseStr)
if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
}

View File

@ -1,6 +1,3 @@
//go:build darwin
// +build darwin
package secret
import (
@ -9,22 +6,19 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
"github.com/spf13/afero"
)
const (
agePrivKeyPassphraseLength = 64
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
)
// keychainItemNameRegex validates keychain item names
@ -113,22 +107,30 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
}
defer agePrivKeyBuffer.Destroy()
DebugWith("Successfully decrypted age private key with keychain passphrase",
slog.String("unlocker_id", k.GetID()),
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
// Create a secure buffer for the private key data
agePrivKeyBuffer := memguard.NewBufferFromBytes(agePrivKeyData)
defer agePrivKeyBuffer.Destroy()
// Clear the original private key data
for i := range agePrivKeyData {
agePrivKeyData[i] = 0
}
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
@ -161,18 +163,15 @@ func (k *KeychainUnlocker) GetDirectory() string {
// GetID implements Unlocker interface - generates ID from keychain item name
func (k *KeychainUnlocker) GetID() string {
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
// This matches the passphrase unlocker format
hostname, err := os.Hostname()
// Generate ID using keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
hostname = "unknown"
// The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
}
// Use the creation timestamp from metadata
createdAt := k.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
return fmt.Sprintf("%s-keychain", keychainItemName)
}
// Remove implements Unlocker interface - removes the keychain unlocker
@ -302,13 +301,13 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
}
// Decrypt long-term private key using current unlocker
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
// Return the decrypted key buffer
return ltPrivKeyBuffer, nil
// Return the decrypted key in a secure buffer
return memguard.NewBufferFromBytes(ltPrivKeyData), nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
@ -369,7 +368,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
@ -410,12 +409,8 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
}
// Create a secure buffer for keychain data
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 8: Store data in keychain
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil {
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
}
@ -447,10 +442,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}, nil
}
// checkMacOSAvailable verifies that we're running on macOS
// checkMacOSAvailable verifies that we're running on macOS and security command is available
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
cmd := exec.Command("/usr/bin/security", "help")
if err := cmd.Run(); err != nil {
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
}
return nil
@ -469,78 +465,59 @@ func validateKeychainItemName(itemName string) error {
return nil
}
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
// storeInKeychain stores data in the macOS keychain using the security command
func storeInKeychain(itemName string, data []byte) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec
"-a", itemName,
"-s", itemName,
"-w", string(data),
"-U") // Update if exists
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData([]byte(data.String()))
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
"-a", itemName,
"-s", itemName,
"-w") // Return password only
results, err := keychain.QueryItem(query)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
// Remove trailing newline if present
if len(output) > 0 && output[len(output)-1] == '\n' {
output = output[:len(output)-1]
}
return results[0].Data, nil
return output, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// deleteFromKeychain removes an item from the macOS keychain using the security command
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
"-a", itemName,
"-s", itemName)
if err := keychain.DeleteItem(item); err != nil {
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to delete item from keychain: %w", err)
}

View File

@ -1,73 +0,0 @@
//go:build !darwin
// +build !darwin
package secret
import (
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
type KeychainUnlockerMetadata struct {
UnlockerMetadata
KeychainItemName string `json:"keychainItemName"`
}
// KeychainUnlocker is a stub for non-Darwin platforms
type KeychainUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// GetIdentity panics on non-Darwin platforms
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
panic("keychain unlockers are only supported on macOS")
}
// GetType panics on non-Darwin platforms
func (k *KeychainUnlocker) GetType() string {
panic("keychain unlockers are only supported on macOS")
}
// GetMetadata panics on non-Darwin platforms
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
panic("keychain unlockers are only supported on macOS")
}
// GetDirectory panics on non-Darwin platforms
func (k *KeychainUnlocker) GetDirectory() string {
panic("keychain unlockers are only supported on macOS")
}
// GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string {
panic("keychain unlockers are only supported on macOS")
}
// GetKeychainItemName panics on non-Darwin platforms
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
panic("keychain unlockers are only supported on macOS")
}
// Remove panics on non-Darwin platforms
func (k *KeychainUnlocker) Remove() error {
panic("keychain unlockers are only supported on macOS")
}
// NewKeychainUnlocker panics on non-Darwin platforms
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
panic("keychain unlockers are only supported on macOS")
}
// CreateKeychainUnlocker panics on non-Darwin platforms
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
panic("keychain unlockers are only supported on macOS")
}
// getLongTermPrivateKey panics on non-Darwin platforms
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
panic("keychain unlockers are only supported on macOS")
}

View File

@ -1,167 +0,0 @@
//go:build darwin
// +build darwin
package secret
import (
"encoding/hex"
"runtime"
"testing"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test data
testItemName := "test-secret-keychain-item"
testData := "test-secret-data-12345"
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
defer testBuffer.Destroy()
// Clean up any existing item first
_ = deleteFromKeychain(testItemName)
// Test 1: Store data in keychain
err := storeInKeychain(testItemName, testBuffer)
require.NoError(t, err, "Failed to store data in keychain")
// Test 2: Retrieve data from keychain
retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve data from keychain")
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
// Test 3: Update existing item (store again with different data)
newTestData := "updated-test-data-67890"
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
defer newTestBuffer.Destroy()
err = storeInKeychain(testItemName, newTestBuffer)
require.NoError(t, err, "Failed to update data in keychain")
// Verify updated data
retrievedData, err = retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve updated data from keychain")
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
// Test 4: Delete from keychain
err = deleteFromKeychain(testItemName)
require.NoError(t, err, "Failed to delete data from keychain")
// Test 5: Verify item is deleted (should fail to retrieve)
_, err = retrieveFromKeychain(testItemName)
assert.Error(t, err, "Expected error when retrieving deleted item")
}
func TestKeychainInvalidItemName(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
testData := memguard.NewBufferFromBytes([]byte("test"))
defer testData.Destroy()
// Test invalid item names
invalidNames := []string{
"", // Empty name
"test space", // Contains space
"test/slash", // Contains slash
"test\\backslash", // Contains backslash
"test:colon", // Contains colon
"test;semicolon", // Contains semicolon
"test|pipe", // Contains pipe
"test@at", // Contains @
"test#hash", // Contains #
"test$dollar", // Contains $
"test&ampersand", // Contains &
"test*asterisk", // Contains *
"test?question", // Contains ?
"test!exclamation", // Contains !
"test'quote", // Contains single quote
"test\"doublequote", // Contains double quote
"test(paren", // Contains parenthesis
"test[bracket", // Contains bracket
}
for _, name := range invalidNames {
err := storeInKeychain(name, testData)
assert.Error(t, err, "Expected error for invalid name: %s", name)
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
}
// Test valid names (should not error on validation)
validNames := []string{
"test-name",
"test_name",
"test.name",
"TestName123",
"TEST_NAME_123",
"com.example.test",
"secret-vault-hostname-2024-01-01",
}
for _, name := range validNames {
err := validateKeychainItemName(name)
assert.NoError(t, err, "Expected no error for valid name: %s", name)
// Clean up
_ = deleteFromKeychain(name)
}
}
func TestKeychainNilData(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test storing nil data
err := storeInKeychain("test-item", nil)
assert.Error(t, err, "Expected error when storing nil data")
assert.Contains(t, err.Error(), "data buffer is nil")
}
func TestKeychainLargeData(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test with larger hex-encoded data (512 bytes of binary data = 1KB hex)
largeData := make([]byte, 512)
for i := range largeData {
largeData[i] = byte(i % 256)
}
// Convert to hex string for storage
hexData := hex.EncodeToString(largeData)
testItemName := "test-large-data"
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
defer testBuffer.Destroy()
// Clean up first
_ = deleteFromKeychain(testItemName)
// Store hex data
err := storeInKeychain(testItemName, testBuffer)
require.NoError(t, err, "Failed to store large data")
// Retrieve and verify
retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve large data")
// Decode hex and compare
decodedData, err := hex.DecodeString(string(retrievedData))
require.NoError(t, err, "Failed to decode hex data")
assert.Equal(t, largeData, decodedData, "Large data mismatch")
// Clean up
_ = deleteFromKeychain(testItemName)
}

View File

@ -76,11 +76,10 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// Test encrypting private key with passphrase
t.Run("EncryptPrivateKey", func(t *testing.T) {
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
defer privKeyBuffer.Destroy()
privKeyData := []byte(agePrivateKey)
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphraseBuffer)
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphraseBuffer)
if err != nil {
t.Fatalf("Failed to encrypt private key: %v", err)
}

View File

@ -84,22 +84,30 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
}
defer privKeyBuffer.Destroy()
DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", privKeyBuffer.Size()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
// Create a secure buffer for the private key data
privKeyBuffer := memguard.NewBufferFromBytes(privKeyData)
defer privKeyBuffer.Destroy()
// Clear the original private key data
for i := range privKeyData {
privKeyData[i] = 0
}
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())

View File

@ -45,10 +45,7 @@ pinentry-mode loopback
origDecryptFunc := secret.GPGDecryptFunc
// Set custom GPG functions for this test
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
if data == nil {
return nil, fmt.Errorf("data buffer is nil")
}
secret.GPGEncryptFunc = func(data []byte, keyID string) ([]byte, error) {
cmd := exec.Command("gpg",
"--homedir", gnupgHomeDir,
"--batch",
@ -63,7 +60,7 @@ pinentry-mode loopback
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(data.Bytes())
cmd.Stdin = bytes.NewReader(data)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
@ -72,7 +69,7 @@ pinentry-mode loopback
return stdout.Bytes(), nil
}
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
secret.GPGDecryptFunc = func(encryptedData []byte) ([]byte, error) {
cmd := exec.Command("gpg",
"--homedir", gnupgHomeDir,
"--batch",
@ -91,8 +88,7 @@ pinentry-mode loopback
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
}
// Create a secure buffer for the decrypted data
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
return stdout.Bytes(), nil
}
// Restore original functions after test
@ -448,9 +444,8 @@ Passphrase: ` + testPassphrase + `
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
privKeyData := []byte(ageIdentity.String())
encryptedOutput, err := secret.GPGEncryptFunc(privKeyData, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}

View File

@ -20,13 +20,11 @@ import (
var (
// GPGEncryptFunc is the function used for GPG encryption
// Can be overridden in tests to provide a non-interactive implementation
//nolint:gochecknoglobals // Required for test mocking
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
GPGEncryptFunc = gpgEncryptDefault //nolint:gochecknoglobals // Required for test mocking
// GPGDecryptFunc is the function used for GPG decryption
// Can be overridden in tests to provide a non-interactive implementation
//nolint:gochecknoglobals // Required for test mocking
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking
// gpgKeyIDRegex validates GPG key IDs
// Allows either:
@ -81,22 +79,21 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Step 2: Decrypt the age private key using GPG
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
if err != nil {
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
}
defer agePrivKeyBuffer.Destroy()
DebugWith("Successfully decrypted age private key with GPG",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
@ -128,7 +125,7 @@ func (p *PGPUnlocker) GetDirectory() string {
// GetID implements Unlocker interface - generates ID from GPG key ID
func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: pgp-<keyid>
// Generate ID using GPG key ID: <keyid>-pgp
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
@ -136,7 +133,7 @@ func (p *PGPUnlocker) GetID() string {
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
}
return fmt.Sprintf("pgp-%s", gpgKeyID)
return fmt.Sprintf("%s-pgp", gpgKeyID)
}
// Remove implements Unlocker interface - removes the PGP unlocker
@ -256,7 +253,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
}
@ -267,7 +264,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
}
// Step 9: Resolve the GPG key ID to its full fingerprint
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
@ -313,8 +310,8 @@ func validateGPGKeyID(keyID string) error {
return nil
}
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func resolveGPGKeyFingerprint(keyID string) (string, error) {
if err := validateGPGKeyID(keyID); err != nil {
return "", fmt.Errorf("invalid GPG key ID: %w", err)
}
@ -351,16 +348,13 @@ func checkGPGAvailable() error {
}
// gpgEncryptDefault is the default implementation of GPG encryption
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
if data == nil {
return nil, fmt.Errorf("data buffer is nil")
}
func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
if err := validateGPGKeyID(keyID); err != nil {
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
}
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
cmd.Stdin = strings.NewReader(data.String())
cmd.Stdin = strings.NewReader(string(data))
output, err := cmd.Output()
if err != nil {
@ -371,7 +365,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
}
// gpgDecryptDefault is the default implementation of GPG decryption
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
cmd := exec.Command("gpg", "--quiet", "--decrypt")
cmd.Stdin = strings.NewReader(string(encryptedData))
@ -380,8 +374,5 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
return nil, fmt.Errorf("GPG decryption failed: %w", err)
}
// Create a secure buffer for the decrypted data
outputBuffer := memguard.NewBufferFromBytes(output)
return outputBuffer, nil
return output, nil
}

View File

@ -62,8 +62,35 @@ func NewSecret(vault VaultInterface, name string) *Secret {
}
}
// Save is deprecated - use vault.AddSecret directly which creates versions
// Kept for backward compatibility
func (s *Secret) Save(value []byte, force bool) error {
DebugWith("Saving secret (deprecated method)",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
slog.Int("value_length", len(value)),
slog.Bool("force", force),
)
// Create a secure buffer for the value - note that the caller
// should ideally pass a LockedBuffer directly to vault.AddSecret
valueBuffer := memguard.NewBufferFromBytes(value)
defer valueBuffer.Destroy()
err := s.vault.AddSecret(s.Name, valueBuffer, force)
if err != nil {
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
return err
}
Debug("Successfully saved secret", "secret_name", s.Name)
return nil
}
// GetValue retrieves and decrypts the current version's value using the provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
@ -179,17 +206,16 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
// Parse the long-term private key
Debug("Parsing long-term private key", "secret_name", s.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)

View File

@ -277,16 +277,15 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
}
// Step 2: Decrypt version private key using long-term key
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
@ -303,17 +302,16 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
}
// Step 5: Decrypt metadata using version key
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
if err != nil {
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version metadata: %w", err)
}
defer metadataBuffer.Destroy()
// Step 6: Unmarshal metadata
var metadata VersionMetadata
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
@ -326,7 +324,7 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
}
// GetValue retrieves and decrypts the version value
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
DebugWith("Getting version value",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
@ -354,17 +352,16 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// Step 2: Decrypt version private key using long-term key
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
@ -384,7 +381,7 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// Step 5: Decrypt value using version key
Debug("Decrypting value with version identity", "version", sv.Version)
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
@ -393,10 +390,10 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
Debug("Successfully retrieved version value",
"version", sv.Version,
"value_length", valueBuffer.Size(),
"is_empty", valueBuffer.Size() == 0)
"value_length", len(value),
"is_empty", len(value) == 0)
return valueBuffer, nil
return value, nil
}
// ListVersions lists all versions of a secret

View File

@ -255,11 +255,10 @@ func TestSecretVersionGetValue(t *testing.T) {
require.NoError(t, err)
// Retrieve the value
retrievedBuffer, err := sv.GetValue(ltIdentity)
retrievedValue, err := sv.GetValue(ltIdentity)
require.NoError(t, err)
defer retrievedBuffer.Destroy()
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
assert.Equal(t, expectedValue, retrievedValue)
}
func TestListVersions(t *testing.T) {

View File

@ -204,12 +204,6 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
if err := newVersion.Save(value); err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName)
// Clean up the secret directory if this was a new secret
if !exists {
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
_ = v.fs.RemoveAll(secretDir)
}
return fmt.Errorf("failed to save version: %w", err)
}
@ -265,14 +259,13 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
}
// Decrypt version private key using long-term key
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
return fmt.Errorf("failed to parse version private key: %w", err)
}
@ -400,26 +393,21 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
return nil, fmt.Errorf("failed to decrypt version: %w", err)
}
// Create a copy to return since the buffer will be destroyed
result := make([]byte, decryptedValue.Size())
copy(result, decryptedValue.Bytes())
decryptedValue.Destroy()
secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(result)),
slog.Int("decrypted_length", len(decryptedValue)),
)
// Debug: Log metadata about the decrypted value without exposing the actual secret
secret.Debug("Vault secret decryption debug info",
"secret_name", name,
"version", version,
"decrypted_value_length", len(result),
"is_empty", len(result) == 0)
"decrypted_value_length", len(decryptedValue),
"is_empty", len(decryptedValue) == 0)
return result, nil
return decryptedValue, nil
}
// UnlockVault unlocks the vault and returns the long-term private key

View File

@ -346,9 +346,7 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
// Encrypt private key with passphrase
privKeyStr := unlockerIdentity.String()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
}

View File

@ -157,23 +157,22 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
slog.Int("decrypted_length", len(ltPrivKeyData)),
)
// Parse long-term private key
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
@ -208,48 +207,3 @@ func (v *Vault) GetName() string {
func (v *Vault) GetFilesystem() afero.Fs {
return v.fs
}
// NumSecrets returns the number of secrets in the vault
func (v *Vault) NumSecrets() (int, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return 0, fmt.Errorf("failed to get vault directory: %w", err)
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(v.fs, secretsDir)
if !exists {
return 0, nil
}
entries, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
}
// Count only directories that contain at least one version file
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Check if this secret directory contains any version files
secretDir := filepath.Join(secretsDir, entry.Name())
versionFiles, err := afero.ReadDir(v.fs, secretDir)
if err != nil {
continue // Skip directories we can't read
}
// Look for at least one version file (excluding "current" symlink)
for _, vFile := range versionFiles {
if !vFile.IsDir() && vFile.Name() != "current" {
count++
break // Found at least one version, count this secret
}
}
}
return count, nil
}

View File

@ -1,87 +0,0 @@
package vault_test
import (
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
// Create in-memory filesystem
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create a vault directory without a public key (simulating the error condition)
vaultDir := filepath.Join(stateDir, "vaults.d", "broken")
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
// Create currentvault symlink
currentVaultPath := filepath.Join(stateDir, "currentvault")
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
// Create vault instance
vlt := vault.NewVault(fs, stateDir, "broken")
// Try to add a secret - this should fail
secretName := "test-secret"
value := memguard.NewBufferFromBytes([]byte("test-value"))
defer value.Destroy()
err := vlt.AddSecret(secretName, value, false)
require.Error(t, err, "AddSecret should fail when public key is missing")
assert.Contains(t, err.Error(), "failed to read long-term public key")
// Verify that the secret directory was NOT created
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
exists, _ := afero.DirExists(fs, secretDir)
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
// Verify the secrets.d directory is empty or doesn't exist
secretsDir := filepath.Join(vaultDir, "secrets.d")
if exists, _ := afero.DirExists(fs, secretsDir); exists {
entries, err := afero.ReadDir(fs, secretsDir)
require.NoError(t, err)
assert.Empty(t, entries, "secrets.d directory should be empty after failed AddSecret")
}
}
func TestAddSecretCleansUpOnFailure(t *testing.T) {
// Create in-memory filesystem
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create a vault directory with public key
vaultDir := filepath.Join(stateDir, "vaults.d", "test")
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
// Create a mock public key that will cause encryption to fail
// by using an invalid age public key format
pubKeyPath := filepath.Join(vaultDir, "pub.age")
require.NoError(t, afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"), secret.FilePerms))
// Create currentvault symlink
currentVaultPath := filepath.Join(stateDir, "currentvault")
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
// Create vault instance
vlt := vault.NewVault(fs, stateDir, "test")
// Try to add a secret - this should fail during encryption
secretName := "test-secret"
value := memguard.NewBufferFromBytes([]byte("test-value"))
defer value.Destroy()
err := vlt.AddSecret(secretName, value, false)
require.Error(t, err, "AddSecret should fail with invalid public key")
// Verify that the secret directory was NOT created
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
exists, _ := afero.DirExists(fs, secretDir)
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
}