Compare commits

...

25 Commits

Author SHA1 Message Date
92c41bdb0c Fix error handling in AddSecret to clean up on failure
- Clean up secret directory if Save() fails for new secrets
- Add tests to verify cleanup behavior
- Ensures failed secret additions don't leave orphaned directories
2025-07-26 22:03:31 +02:00
75c3d22b62 Fix vault creation to require mnemonic and set up initial unlocker
- Vault creation now prompts for mnemonic if not in environment
- Automatically creates passphrase unlocker during vault creation
- Prevents 'missing public key' error when adding secrets to new vaults
- Updates tests to reflect new vault creation flow
2025-07-26 21:58:57 +02:00
a6f24e9581 Fix --keyid flag scope and implement secret move command
- Restrict --keyid flag to PGP unlocker type only
- Add validation to prevent --keyid usage with non-PGP unlockers
- Implement 'secret move' command with 'mv' and 'rename' aliases
- Add comprehensive tests for move functionality
- Update documentation to reflect optional nature of --keyid for PGP

The move command allows renaming or moving secrets within a vault while
preserving all versions and metadata. It fails if the destination already
exists to prevent accidental overwrites.
2025-07-26 01:26:27 +02:00
a73a409fe4 Refactor unlockers command structure and add quiet flag to list command
- Rename 'unlockers' command to 'unlocker' for consistency
- Move all unlocker subcommands (list, add, remove) under single 'unlocker' command
- Add --quiet/-q flag to 'secret list' for scripting support
- Update documentation and tests to reflect command changes

The quiet flag outputs only secret names without headers or formatting,
making it ideal for shell script usage like: secret get $(secret list -q | head -1)
2025-07-22 16:04:44 +02:00
70d19d09d0 latest 2025-07-22 13:35:19 +02:00
40ea47b2a1 Add missing changes from feature branch
- Update Makefile to run lint and vet before tests
- Add install target to Makefile
- Fix keychainunlocker_stub.go for non-Darwin platforms
2025-07-22 12:51:02 +02:00
7ed3e287ea Merge branch 'add-list-remove-commands' 2025-07-22 12:47:20 +02:00
8e3530a510 Fix use-after-free crash in readSecurePassphrase
The function was using defer to destroy password buffers, which caused
the buffers to be freed before the function returned. This led to a
SIGBUS error when trying to access the destroyed buffer's memory.

Changed to manual memory management to ensure buffers are only destroyed
when no longer needed, and the first buffer is returned directly to the
caller who is responsible for destroying it.
2025-07-22 12:46:16 +02:00
e5d7407c79 Fix mnemonic input to not echo to screen
Changed mnemonic input to use secure non-echoing input like passphrases:
- Use secret.ReadPassphrase() instead of readLineFromStdin()
- Add newline after hidden input for better UX
- Remove unused stdin reading functions from cli.go

This prevents sensitive mnemonic phrases from being displayed on screen
during input, matching the security behavior of passphrase input.
2025-07-22 12:39:32 +02:00
377b51f2db Add Docker support for building and running the CLI tool
- Add DOCKER_HOST export to Makefile for remote Docker daemon
- Create multi-stage Dockerfile:
  - Build stage: golang:1.24-alpine with gcc, make, git
  - Runtime stage: alpine with ca-certificates, gnupg
  - Runs as non-root 'secret' user
- Add Makefile targets:
  - docker: build container as sneak/secret
  - docker-run: run container interactively
- Add .dockerignore to exclude build artifacts but keep .git
  for potential linker flags

Container includes GPG support for PGP unlockers and runs on Linux,
making it suitable for cross-platform testing and deployment.
2025-07-21 22:13:19 +02:00
a09fa89f30 Fix cross-platform build issues and security vulnerabilities
- Add build tags to keychain implementation files (Darwin-only)
- Create stub implementations for non-Darwin platforms that panic
- Conditionally show keychain support in help text based on platform
- Platform check in UnlockersAdd prevents keychain usage on non-Darwin
- Verified GPG operations already protected against command injection
  via validateGPGKeyID() and proper exec.Command argument passing
- Keychain operations use go-keychain library, no shell commands

The application now builds and runs on Linux/non-Darwin platforms with
keychain functionality properly isolated to macOS only.
2025-07-21 22:05:23 +02:00
7af1e6efa8 Improve PGP unlocker ergonomics
- Support 'secret unlockers add pgp [keyid]' positional argument syntax
- Automatically detect and use default GPG key when no key is specified
- Change PGP unlocker ID format from <keyid>-pgp to pgp-<keyid>
- Check if PGP key is already added before creating duplicate unlocker
- Add getDefaultGPGKey() that checks gpgconf first, then falls back to
  first secret key
- Export ResolveGPGKeyFingerprint() for use in CLI
- Add checkUnlockerExists() helper to verify unlocker IDs

The new behavior:
- 'secret unlockers add pgp' uses default GPG key
- 'secret unlockers add pgp KEYID' uses specified key
- 'secret unlockers add pgp --keyid=KEYID' also works
- Errors if key is already added or no default key exists
2025-07-21 18:57:58 +02:00
09b3a1fcdc Remove internal/macse package and fix all linter issues
- Remove internal/macse package (Secure Enclave experiment)
- Fix errcheck: handle keychain.DeleteItem error return
- Fix lll: break long lines in command descriptions
- Fix mnd: add nolint comment for cobra.ExactArgs(2)
- Fix nlreturn: add blank lines before return/break statements
- Fix revive: add nolint comment for KEYCHAIN_APP_IDENTIFIER constant
- Fix nestif: simplify UnlockersRemove by using new NumSecrets method
- Add NumSecrets() method to vault.Vault for counting secrets
- Update golangci.yml to exclude ALL_CAPS warning (attempted various
  configurations but settled on nolint comment)

All tests pass, code is formatted and linted.
2025-07-21 17:48:47 +02:00
816f53f819 Replace shell-based keychain implementation with keybase/go-keychain library
- Replaced exec.Command calls to /usr/bin/security with native keybase/go-keychain API
- Added comprehensive test suite for keychain operations
- Fixed binary data storage in tests using hex encoding
- Updated macse tests to skip with explanation about ADE requirements
- All tests passing with CGO_ENABLED=1
2025-07-21 15:58:41 +02:00
bba1fb21e6 docs 2025-07-15 19:01:29 +02:00
d4f557631b prototype secure enclave interface 2025-07-15 09:37:02 +02:00
e53161188c Fix remaining memory security issues
- Fixed gpgDecryptDefault to return *memguard.LockedBuffer instead of []byte
- Updated GPGDecryptFunc signature and all implementations
- Confirmed getSecretValue already returns LockedBuffer (was fixed earlier)
- Improved passphrase string handling by removing intermediate variables
- Note: String conversion for passphrases is unavoidable due to age library API
- All GPG decrypted data is now immediately protected in memory
2025-07-15 09:08:51 +02:00
ff17b9b107 Update TODO.md - DecryptWithPassphrase already fixed
- DecryptWithPassphrase was automatically fixed when we updated DecryptWithIdentity
- It now returns LockedBuffer since it calls DecryptWithIdentity internally
2025-07-15 09:04:59 +02:00
63cc06b93c Fix DecryptWithIdentity to return LockedBuffer
- Changed DecryptWithIdentity to return *memguard.LockedBuffer instead of []byte
- Updated all callers throughout the codebase to handle LockedBuffer
- This ensures decrypted data is protected in memory immediately after decryption
- Fixed all usages in vault, secret, version, and unlocker implementations
- Removed duplicate buffer creation and unnecessary memory clearing
2025-07-15 09:04:34 +02:00
8ec3fc877d Fix GetValue methods to return LockedBuffer internally
- Changed Secret.GetValue and Version.GetValue to return *memguard.LockedBuffer
- Updated all internal callers to handle LockedBuffer properly
- For backward compatibility, vault.GetSecret still returns []byte but makes a copy
- This ensures secret values are protected in memory during decryption
- Updated tests to handle LockedBuffer returns
- Fixed CLI getSecretValue to use LockedBuffer throughout
2025-07-15 08:59:23 +02:00
819902f385 Fix gpgEncryptDefault to accept LockedBuffer for data parameter
- Changed GPGEncryptFunc signature to accept *memguard.LockedBuffer instead of []byte
- Updated gpgEncryptDefault implementation to use LockedBuffer
- Updated all callers including tests to pass LockedBuffer
- This ensures GPG encryption data is protected in memory
- Fixed linter issue with line length
2025-07-15 08:46:33 +02:00
292564c6e7 Fix storeInKeychain to accept LockedBuffer for data parameter
- Changed storeInKeychain to accept *memguard.LockedBuffer instead of []byte
- Updated caller in CreateKeychainUnlocker to create LockedBuffer before storing
- This ensures keychain data is protected in memory before being stored
- Added proper buffer cleanup with defer Destroy()
2025-07-15 08:44:09 +02:00
eef2332823 Fix EncryptWithPassphrase to accept LockedBuffer for data parameter
- Changed EncryptWithPassphrase to accept *memguard.LockedBuffer instead of []byte
- Updated all callers to pass LockedBuffer:
  - CreatePassphraseUnlocker in vault/unlockers.go
  - Keychain unlocker in keychainunlocker.go
  - Tests in passphrase_test.go
- Removed intermediate dataBuffer creation since data is now already protected
- This ensures sensitive data is protected in memory throughout encryption
2025-07-15 08:42:46 +02:00
e82d428b05 Remove deprecated Secret.Save function
- Removed unused deprecated Save(value []byte, force bool) function
- This function accepted unprotected secret data which was a security issue
- All code now uses vault.AddSecret directly with LockedBuffer
- Updated TODO.md to reflect completion of this security fix
2025-07-15 08:40:35 +02:00
9cbe055791 fmt 2025-07-15 08:33:16 +02:00
41 changed files with 2537 additions and 480 deletions

View File

@ -1,30 +0,0 @@
{
"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": []
}
}

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
# 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,3 +5,4 @@
cli.test cli.test
vault.test vault.test
*.test *.test
settings.local.json

View File

@ -64,6 +64,14 @@ linters-settings:
nlreturn: nlreturn:
block-size: 2 block-size: 2
revive:
rules:
- name: var-naming
arguments:
- []
- []
- "upperCaseConst=true"
tagliatelle: tagliatelle:
case: case:
rules: rules:
@ -89,3 +97,32 @@ issues:
- text: "parameter '(args|cmd)' seems to be unused" - text: "parameter '(args|cmd)' seems to be unused"
linters: linters:
- revive - 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,3 +26,5 @@ Read the rules in AGENTS.md and follow them.
* Do not stop working on a task until you have reached the definition of * 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 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. 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.

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# 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,15 +1,23 @@
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 default: check
build: ./secret build: ./secret
# Simple build (no code signing needed) ./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
./secret: go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
go build -v -o $@ cmd/secret/main.go
vet: vet:
go vet ./... go vet ./...
test: test: lint vet
go test ./... || go test -v ./... go test ./... || go test -v ./...
fmt: fmt:
@ -18,9 +26,19 @@ fmt:
lint: lint:
golangci-lint run --timeout 5m golangci-lint run --timeout 5m
# Check all code quality (build + vet + lint + unit tests) check: build test
check: ./secret vet lint test
# Build Docker container
docker:
docker build -t sneak/secret .
# Run Docker container interactively
docker-run:
docker run --rm -it sneak/secret
# Clean build artifacts # Clean build artifacts
clean: clean:
rm -f ./secret rm -f ./secret
install: ./secret
cp ./secret $(HOME)/bin/secret

112
README.md
View File

@ -1,12 +1,12 @@
# Secret - Hierarchical Secret Manager # Secret - Hierarchical Secret Manager
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. 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.
## Core Architecture ## Core Architecture
### Three-Layer Key Hierarchy ### Three-Layer Key Hierarchy
Secret implements a sophisticated three-layer key architecture: Secret implements a three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption 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 2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
@ -16,7 +16,7 @@ Secret implements a sophisticated three-layer key architecture:
Each secret maintains a history of versions, with each version having: Each secret maintains a history of versions, with each version having:
- Its own encryption key pair - Its own encryption key pair
- Encrypted metadata including creation time and validity period - Metadata (unencrypted) including creation time and validity period
- Immutable value storage - Immutable value storage
- Atomic version switching via symlink updates - 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 ### Vault Management
#### `secret vault list [--json]` #### `secret vault list [--json]` / `secret vault ls`
Lists all available vaults. Lists all available vaults. The current vault is marked.
#### `secret vault create <name>` #### `secret vault create <name>`
Creates a new vault with the specified name. Creates a new vault with the specified name.
@ -78,6 +78,12 @@ Creates a new vault with the specified name.
#### `secret vault select <name>` #### `secret vault select <name>`
Switches to the specified vault for subsequent operations. 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 Management
#### `secret add <secret-name> [--force]` #### `secret add <secret-name> [--force]`
@ -95,14 +101,29 @@ Retrieves and outputs a secret value to stdout.
#### `secret list [filter] [--json]` / `secret ls` #### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring matching. 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 ### Version Management
#### `secret version list <secret-name>` #### `secret version list <secret-name>` / `secret version ls`
Lists all versions of a secret showing creation time, status, and validity period. Lists all versions of a secret showing creation time, status, and validity period.
#### `secret version promote <secret-name> <version>` #### `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. 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 ### Key Generation
#### `secret generate mnemonic` #### `secret generate mnemonic`
@ -116,21 +137,26 @@ Generates and stores a random secret.
### Unlocker Management ### Unlocker Management
#### `secret unlockers list [--json]` #### `secret unlocker list [--json]` / `secret unlocker ls`
Lists all unlockers in the current vault with their metadata. Lists all unlockers in the current vault with their metadata.
#### `secret unlockers add <type> [options]` #### `secret unlocker add <type> [options]`
Creates a new unlocker of the specified type: Creates a new unlocker of the specified type:
**Types:** **Types:**
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only)
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (required for PGP type) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
#### `secret unlockers rm <unlocker-id>` #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
Removes an unlocker. **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 unlocker select <unlocker-id>` #### `secret unlocker select <unlocker-id>`
Selects an unlocker as the current default for operations. Selects an unlocker as the current default for operations.
@ -169,7 +195,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ │ │ │ ├── pub.age # Version public key │ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted) │ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value │ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.age # Encrypted metadata │ │ │ │ │ │ └── metadata.json # Unencrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version │ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001 │ │ │ │ └── current -> versions/20231216.001
│ │ │ └── database%password/ # Secret: database/password │ │ │ └── database%password/ # Secret: database/password
@ -207,6 +233,18 @@ Unlockers provide different authentication methods to access the long-term keys:
- Leverages existing key management workflows - Leverages existing key management workflows
- Strong authentication through GPG - 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. 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 #### Secret-specific Keys
@ -241,6 +279,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
### Hardware Integration ### Hardware Integration
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security
- Secure Enclave support planned (requires Apple Developer Program)
## Examples ## Examples
@ -259,6 +299,9 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
secret list secret list
secret get database/prod/password secret get database/prod/password
secret get services/api/key secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret remove ssh/servers/web01
``` ```
### Multi-vault Setup ### Multi-vault Setup
@ -270,7 +313,7 @@ secret vault create personal
# Work with work vault # Work with work vault
secret vault select work secret vault select work
echo "work-db-pass" | secret add database/password echo "work-db-pass" | secret add database/password
secret unlockers add passphrase # Add passphrase authentication secret unlocker add passphrase # Add passphrase authentication
# Switch to personal vault # Switch to personal vault
secret vault select personal secret vault select personal
@ -278,19 +321,38 @@ echo "personal-email-pass" | secret add email/password
# List all vaults # List all vaults
secret vault list secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force
``` ```
### Advanced Authentication ### Advanced Authentication
```bash ```bash
# Add multiple unlock methods # Add multiple unlock methods
secret unlockers add passphrase # Password-based secret unlocker add passphrase # Password-based
secret unlockers add pgp --keyid ABCD1234 # GPG key secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
# List unlockers # List unlockers
secret unlockers list secret unlocker list
# Select a specific unlocker # Select a specific unlocker
secret unlocker select <unlocker-id> 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 ### Encryption/Decryption with Age Keys
@ -316,7 +378,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### File Formats ### File Formats
- **Age Files**: Standard Age encryption format (.age extension) - **Age Files**: Standard Age encryption format (.age extension)
- **Metadata**: JSON format with timestamps and type information - **Metadata**: Unencrypted JSON format with timestamps and type information
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash - **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
### Vault Management ### Vault Management
@ -325,8 +387,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 - **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
### Cross-Platform Support ### Cross-Platform Support
- **macOS**: Full support including Keychain integration - **macOS**: Full support including Keychain and planned Secure Enclave integration
- **Linux**: Full support (excluding Keychain features) - **Linux**: Full support (excluding macOS-specific features)
- **Windows**: Basic support (filesystem operations only) - **Windows**: Basic support (filesystem operations only)
## Security Considerations ## Security Considerations
@ -367,9 +429,19 @@ go test -tags=integration -v ./internal/cli # Integration tests
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers - **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems - **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,6 +4,51 @@ This document outlines the bugs, issues, and improvements that need to be
addressed before the 1.0 release of the secret manager. Items are addressed before the 1.0 release of the secret manager. Items are
prioritized from most critical (top) to least critical (bottom). 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 ## Code Cleanups
* we shouldn't be passing around a statedir, it should be read from the * we shouldn't be passing around a statedir, it should be read from the

102
coverage.out Normal file
View File

@ -0,0 +1,102 @@
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,6 +9,7 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d 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/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
@ -23,7 +24,11 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.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/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/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect

13
go.sum
View File

@ -43,6 +43,10 @@ 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 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= 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/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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 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= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -63,7 +67,14 @@ 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 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/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/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/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/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 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
@ -117,6 +128,8 @@ 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-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-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-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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=

View File

@ -2,21 +2,11 @@
package cli package cli
import ( import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "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 // Instance encapsulates all CLI functionality and state
type Instance struct { type Instance struct {
fs afero.Fs fs afero.Fs
@ -67,33 +57,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string { func (cli *Instance) GetStateDir() string {
return cli.stateDir 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

@ -0,0 +1,64 @@
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
}

144
internal/cli/completions.go Normal file
View File

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

161
internal/cli/info.go Normal file
View File

@ -0,0 +1,161 @@
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

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

View File

@ -18,6 +18,11 @@ import (
"github.com/stretchr/testify/require" "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 // TestMain runs before all tests and ensures the binary is built
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// Get the current working directory // Get the current working directory
@ -60,7 +65,6 @@ func TestSecretManagerIntegration(t *testing.T) {
} }
// Test configuration // Test configuration
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testPassphrase := "test-passphrase-123" testPassphrase := "test-passphrase-123"
// Create a temporary directory for our vault // Create a temporary directory for our vault
@ -125,7 +129,8 @@ func TestSecretManagerIntegration(t *testing.T) {
// - work vault has pub.age file // - work vault has pub.age file
// - work vault has unlockers.d/passphrase directory // - work vault has unlockers.d/passphrase directory
// - Unlocker metadata and encrypted keys present // - Unlocker metadata and encrypted keys present
test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv) // NOTE: Skipped because vault creation now includes mnemonic import
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// Test 5: Add secrets with versioning // Test 5: Add secrets with versioning
// Command: echo "password123" | secret add database/password // Command: echo "password123" | secret add database/password
@ -177,14 +182,26 @@ func TestSecretManagerIntegration(t *testing.T) {
// Expected: Shows database/password with metadata // Expected: Shows database/password with metadata
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin) 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 // Test 12: Add secrets with different name formats
// Commands: Various secret names (paths, dots, underscores) // Commands: Various secret names (paths, dots, underscores)
// Purpose: Test secret name validation and storage encoding // Purpose: Test secret name validation and storage encoding
// Expected: Proper filesystem encoding (/ -> %) // Expected: Proper filesystem encoding (/ -> %)
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin) 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 // Test 13: Unlocker management
// Commands: secret unlockers list, secret unlockers add pgp // Commands: secret unlocker list, secret unlocker add pgp
// Purpose: Test multiple unlocker types // Purpose: Test multiple unlocker types
// Expected filesystem: // Expected filesystem:
// - Multiple directories under unlockers.d/ // - Multiple directories under unlockers.d/
@ -440,6 +457,12 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
} }
func test03CreateVault(t *testing.T, tempDir string, 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 // Create work vault
output, err := runSecret("vault", "create", "work") output, err := runSecret("vault", "create", "work")
require.NoError(t, err, "vault create should succeed") require.NoError(t, err, "vault create should succeed")
@ -468,9 +491,9 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
secretsDir := filepath.Join(workVaultDir, "secrets.d") secretsDir := filepath.Join(workVaultDir, "secrets.d")
verifyFileExists(t, secretsDir) verifyFileExists(t, secretsDir)
// Verify that work vault does NOT have a long-term key yet (no mnemonic imported) // Verify that work vault has a long-term key (mnemonic was provided)
pubKeyFile := filepath.Join(workVaultDir, "pub.age") pubKeyFile := filepath.Join(workVaultDir, "pub.age")
verifyFileNotExists(t, pubKeyFile) verifyFileExists(t, pubKeyFile)
// List vaults to verify both exist // List vaults to verify both exist
output, err = runSecret("vault", "list") output, err = runSecret("vault", "list")
@ -901,6 +924,81 @@ func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...stri
assert.True(t, secretNames["database/password"], "should have database/password") 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)) { 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 // Make sure we're in default vault
runSecret := func(args ...string) (string, error) { runSecret := func(args ...string) (string, error) {
@ -1010,15 +1108,89 @@ 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)) { 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 // Make sure we're in default vault
_, err := runSecret("vault", "select", "default") _, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed") require.NoError(t, err, "vault select should succeed")
// List unlockers // List unlockers
output, err := runSecret("unlockers", "list") output, err := runSecret("unlocker", "list")
require.NoError(t, err, "unlockers list should succeed") require.NoError(t, err, "unlocker list should succeed")
t.Logf("DEBUG: unlockers list output: %q", output) t.Logf("DEBUG: unlocker list output: %q", output)
// Should have the passphrase unlocker created during init // Should have the passphrase unlocker created during init
assert.Contains(t, output, "passphrase", "should have passphrase unlocker") assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
@ -1027,15 +1199,15 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
output, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
"SB_UNLOCK_PASSPHRASE": "another-passphrase", "SB_UNLOCK_PASSPHRASE": "another-passphrase",
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key "SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
}, "unlockers", "add", "passphrase") }, "unlocker", "add", "passphrase")
if err != nil { if err != nil {
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output) t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
} }
require.NoError(t, err, "add passphrase unlocker should succeed") require.NoError(t, err, "add passphrase unlocker should succeed")
// List unlockers again - should have 2 now // List unlockers again - should have 2 now
output, err = runSecret("unlockers", "list") output, err = runSecret("unlocker", "list")
require.NoError(t, err, "unlockers list should succeed") require.NoError(t, err, "unlocker list should succeed")
// Count passphrase unlockers // Count passphrase unlockers
lines := strings.Split(output, "\n") lines := strings.Split(output, "\n")
@ -1051,8 +1223,8 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker") assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
// Test JSON output // Test JSON output
jsonOutput, err := runSecret("unlockers", "list", "--json") jsonOutput, err := runSecret("unlocker", "list", "--json")
require.NoError(t, err, "unlockers list --json should succeed") require.NoError(t, err, "unlocker list --json should succeed")
var response map[string]interface{} var response map[string]interface{}
err = json.Unmarshal([]byte(jsonOutput), &response) err = json.Unmarshal([]byte(jsonOutput), &response)
@ -1536,10 +1708,10 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
// Test secret list --json (already tested in test 11) // Test secret list --json (already tested in test 11)
// Test unlockers list --json (already tested in test 13) // Test unlocker list --json (already tested in test 13)
// All JSON outputs verified to be valid and contain expected fields // All JSON outputs verified to be valid and contain expected fields
t.Log("JSON output formats verified for vault list, secret list, and unlockers list") t.Log("JSON output formats verified for vault list, secret list, and unlocker list")
} }
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { 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,12 +34,15 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd()) cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd()) cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd()) cmd.AddCommand(newListCmd())
cmd.AddCommand(newUnlockersCmd()) cmd.AddCommand(newRemoveCmd())
cmd.AddCommand(newMoveCmd())
cmd.AddCommand(newUnlockerCmd()) cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd()) cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd()) cmd.AddCommand(newDecryptCmd())
cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newInfoCmd())
cmd.AddCommand(newCompletionCmd())
secret.Debug("newRootCmd completed") secret.Debug("newRootCmd completed")

View File

@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"path/filepath"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -37,10 +39,12 @@ func newAddCmd() *cobra.Command {
} }
func newGetCmd() *cobra.Command { func newGetCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "get <secret-name>", Use: "get <secret-name>",
Short: "Retrieve a secret from the vault", Short: "Retrieve a secret from the vault",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
version, _ := cmd.Flags().GetString("version") version, _ := cmd.Flags().GetString("version")
cli := NewCLIInstance() cli := NewCLIInstance()
@ -63,6 +67,7 @@ func newListCmd() *cobra.Command {
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
quietOutput, _ := cmd.Flags().GetBool("quiet")
var filter string var filter string
if len(args) > 0 { if len(args) > 0 {
@ -71,11 +76,12 @@ func newListCmd() *cobra.Command {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.ListSecrets(cmd, jsonOutput, filter) return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
}, },
} }
cmd.Flags().Bool("json", false, "Output in JSON format") cmd.Flags().Bool("json", false, "Output in JSON format")
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
return cmd return cmd
} }
@ -103,6 +109,53 @@ func newImportCmd() *cobra.Command {
return cmd 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 // updateBufferSize updates the buffer size based on usage pattern
func updateBufferSize(currentSize int, sameSize *int) int { func updateBufferSize(currentSize int, sameSize *int) int {
*sameSize++ *sameSize++
@ -264,7 +317,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
} }
// ListSecrets lists all secrets in the current vault // ListSecrets lists all secrets in the current vault
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error { func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@ -320,15 +373,21 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
return fmt.Errorf("failed to marshal JSON: %w", err) return fmt.Errorf("failed to marshal JSON: %w", err)
} }
cmd.Println(string(jsonBytes)) _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
} else if quietOutput {
// Quiet output - just secret names
for _, secretName := range filteredSecrets {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
}
} else { } else {
// Pretty table output // Pretty table output
out := cmd.OutOrStdout()
if len(filteredSecrets) == 0 { if len(filteredSecrets) == 0 {
if filter != "" { if filter != "" {
cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) _, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
} else { } else {
cmd.Println("No secrets found in current vault.") _, _ = fmt.Fprintln(out, "No secrets found in current vault.")
cmd.Println("Run 'secret add <name>' to create one.") _, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
} }
return nil return nil
@ -336,12 +395,25 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
// Get current vault name for display // Get current vault name for display
if filter != "" { if filter != "" {
cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) _, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
} else { } else {
cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName()) _, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
} }
cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
cmd.Printf("%-40s %-20s\n", "----", "------------") // 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")), "------------")
for _, secretName := range filteredSecrets { for _, secretName := range filteredSecrets {
lastUpdated := "unknown" lastUpdated := "unknown"
@ -349,14 +421,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
metadata := secretObj.GetMetadata() metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
} }
cmd.Printf("%-40s %-20s\n", secretName, lastUpdated) _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
} }
cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets)) _, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
if filter != "" { if filter != "" {
cmd.Printf(" (filtered from %d)", len(secrets)) _, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
} }
cmd.Println() _, _ = fmt.Fprintln(out)
} }
return nil return nil
@ -448,3 +520,93 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
return nil 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,7 +4,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@ -15,27 +17,81 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Import from init.go // 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"`
}
// ... existing imports ... // Table formatting constants
const (
unlockerIDWidth = 40
unlockerTypeWidth = 12
unlockerDateWidth = 20
unlockerFlagsWidth = 20
)
func newUnlockersCmd() *cobra.Command { // 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 {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "unlockers", Use: "unlocker",
Short: "Manage unlockers", Short: "Manage unlockers",
Long: `Create, list, and remove unlockers for the current vault.`, Long: `Create, list, and remove unlockers for the current vault.`,
} }
cmd.AddCommand(newUnlockersListCmd()) cmd.AddCommand(newUnlockerListCmd())
cmd.AddCommand(newUnlockersAddCmd()) cmd.AddCommand(newUnlockerAddCmd())
cmd.AddCommand(newUnlockersRmCmd()) cmd.AddCommand(newUnlockerRemoveCmd())
cmd.AddCommand(newUnlockerSelectCmd())
return cmd return cmd
} }
func newUnlockersListCmd() *cobra.Command { func newUnlockerListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Aliases: []string{"ls"},
Short: "List unlockers in the current vault", Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
@ -52,54 +108,115 @@ func newUnlockersListCmd() *cobra.Command {
return cmd return cmd
} }
func newUnlockersAddCmd() *cobra.Command { 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.`
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add <type>", Use: "add <type>",
Short: "Add a new unlocker", Short: "Add a new unlocker",
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`, 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),
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
unlockerType := args[0]
return cli.UnlockersAdd(args[0], cmd) // 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)
}, },
} }
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers") cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
return cmd return cmd
} }
func newUnlockersRmCmd() *cobra.Command { func newUnlockerRemoveCmd() *cobra.Command {
return &cobra.Command{ cli := NewCLIInstance()
Use: "rm <unlocker-id>", cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
Short: "Remove an unlocker", 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), Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.UnlockersRemove(args[0]) return cli.UnlockersRemove(args[0], force, cmd)
}, },
} }
}
func newUnlockerCmd() *cobra.Command { cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
cmd := &cobra.Command{
Use: "unlocker",
Short: "Manage current unlocker",
Long: `Select the current unlocker for operations.`,
}
cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd return cmd
} }
func newUnlockerSelectSubCmd() *cobra.Command { func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{ return &cobra.Command{
Use: "select <unlocker-id>", Use: "select <unlocker-id>",
Short: "Select an unlocker as current", Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
@ -116,6 +233,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
return err return err
} }
// Get the current unlocker ID
var currentUnlockerID string
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err == nil {
currentUnlockerID = currentUnlocker.GetID()
}
// Get the metadata first // Get the metadata first
unlockerMetadataList, err := vlt.ListUnlockers() unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
@ -123,13 +247,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} }
// Load actual unlocker objects to get the proper IDs // 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 var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList { for _, metadata := range unlockerMetadataList {
// Create unlocker instance to get the proper ID // Create unlocker instance to get the proper ID
@ -195,14 +312,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
Type: metadata.Type, Type: metadata.Type,
CreatedAt: metadata.CreatedAt, CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags, Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID,
} }
unlockers = append(unlockers, unlockerInfo) unlockers = append(unlockers, unlockerInfo)
} }
if jsonOutput { if jsonOutput {
// JSON output return cli.printUnlockersJSON(unlockers, currentUnlockerID)
}
return cli.printUnlockersTable(unlockers)
}
// printUnlockersJSON prints unlockers in JSON format
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
output := map[string]interface{}{ output := map[string]interface{}{
"unlockers": unlockers, "unlockers": unlockers,
"currentUnlockerID": currentUnlockerID,
} }
jsonBytes, err := json.MarshalIndent(output, "", " ") jsonBytes, err := json.MarshalIndent(output, "", " ")
@ -211,24 +337,35 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} }
cli.cmd.Println(string(jsonBytes)) cli.cmd.Println(string(jsonBytes))
} else {
// Pretty table output return nil
}
// printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
if len(unlockers) == 0 { if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.") cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.") cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
return nil return nil
} }
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----") cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
for _, unlocker := range unlockers { for _, unlocker := range unlockers {
flags := "" flags := ""
if len(unlocker.Flags) > 0 { if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",") flags = strings.Join(unlocker.Flags, ",")
} }
cli.cmd.Printf("%-18s %-12s %-20s %s\n", prefix := " "
if unlocker.IsCurrent {
prefix = "* "
}
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
prefix,
unlocker.ID, unlocker.ID,
unlocker.Type, unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"), unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
@ -236,13 +373,18 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} }
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
}
return nil return nil
} }
// UnlockersAdd adds a new unlocker // UnlockersAdd adds a new unlocker
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error { 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 { switch unlockerType {
case "passphrase": case "passphrase":
// Get current vault // Get current vault
@ -274,9 +416,20 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) 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 return nil
case "keychain": case "keychain":
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS")
}
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir) keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err) return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
@ -287,17 +440,52 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Keychain Item Name: %s\n", keyName) 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 return nil
case "pgp": case "pgp":
// Get GPG key ID from flag or environment variable // Get GPG key ID from flag, environment, or default key
var gpgKeyID string var gpgKeyID string
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" { if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
gpgKeyID = flagKeyID gpgKeyID = flagKeyID
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" { } else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
gpgKeyID = envKeyID gpgKeyID = envKeyID
} else { } else {
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable") // 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)
} }
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID) pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
@ -308,22 +496,63 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID()) cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID) 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 return nil
default: default:
return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType) return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
} }
} }
// UnlockersRemove removes an unlocker // UnlockersRemove removes an unlocker with safety checks
func (cli *Instance) UnlockersRemove(unlockerID string) error { func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return err
} }
return vlt.RemoveUnlocker(unlockerID) // 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
} }
// UnlockerSelect selects an unlocker as current // UnlockerSelect selects an unlocker as current
@ -336,3 +565,69 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) 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,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -27,6 +28,7 @@ func newVaultCmd() *cobra.Command {
cmd.AddCommand(newVaultCreateCmd()) cmd.AddCommand(newVaultCreateCmd())
cmd.AddCommand(newVaultSelectCmd()) cmd.AddCommand(newVaultSelectCmd())
cmd.AddCommand(newVaultImportCmd()) cmd.AddCommand(newVaultImportCmd())
cmd.AddCommand(newVaultRemoveCmd())
return cmd return cmd
} }
@ -34,6 +36,7 @@ func newVaultCmd() *cobra.Command {
func newVaultListCmd() *cobra.Command { func newVaultListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Aliases: []string{"ls"},
Short: "List available vaults", Short: "List available vaults",
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
@ -63,10 +66,13 @@ func newVaultCreateCmd() *cobra.Command {
} }
func newVaultSelectCmd() *cobra.Command { func newVaultSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{ return &cobra.Command{
Use: "select <name>", Use: "select <name>",
Short: "Select a vault as current", Short: "Select a vault as current",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
@ -76,11 +82,14 @@ func newVaultSelectCmd() *cobra.Command {
} }
func newVaultImportCmd() *cobra.Command { func newVaultImportCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{ return &cobra.Command{
Use: "import <vault-name>", Use: "import <vault-name>",
Short: "Import a mnemonic into a vault", Short: "Import a mnemonic into a vault",
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`, Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
vaultName := "default" vaultName := "default"
if len(args) > 0 { if len(args) > 0 {
@ -94,6 +103,29 @@ 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 // ListVaults lists all available vaults
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir) vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
@ -147,12 +179,95 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error { func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir) 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) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
if err != nil { if err != nil {
return err 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("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 return nil
} }
@ -295,3 +410,90 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
return nil 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,8 +34,10 @@ func VersionCommands(cli *Instance) *cobra.Command {
// List versions command // List versions command
listCmd := &cobra.Command{ listCmd := &cobra.Command{
Use: "list <secret-name>", Use: "list <secret-name>",
Aliases: []string{"ls"},
Short: "List all versions of a secret", Short: "List all versions of a secret",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return cli.ListVersions(cmd, args[0]) return cli.ListVersions(cmd, args[0])
}, },
@ -47,12 +49,40 @@ func VersionCommands(cli *Instance) *cobra.Command {
Short: "Promote a specific version to current", Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified version without modifying timestamps", 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 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 { RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(cmd, args[0], args[1]) return cli.PromoteVersion(cmd, args[0], args[1])
}, },
} }
versionCmd.AddCommand(listCmd, promoteCmd) // 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)
return versionCmd return versionCmd
} }
@ -207,3 +237,60 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
return nil 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 // DecryptWithIdentity decrypts data with an identity using age
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) { func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
r, err := age.Decrypt(bytes.NewReader(data), identity) r, err := age.Decrypt(bytes.NewReader(data), identity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create decryptor: %w", err) return nil, fmt.Errorf("failed to create decryptor: %w", err)
@ -65,40 +65,40 @@ func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
return nil, fmt.Errorf("failed to read decrypted data: %w", err) return nil, fmt.Errorf("failed to read decrypted data: %w", err)
} }
return result, nil // Create a secure buffer for the decrypted data
resultBuffer := memguard.NewBufferFromBytes(result)
return resultBuffer, nil
} }
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption // EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
// The passphrase parameter should be a LockedBuffer for secure memory handling // Both data and passphrase parameters should be LockedBuffers for secure memory handling
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) { func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
if data == nil {
return nil, fmt.Errorf("data buffer is nil")
}
if passphrase == nil { if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil") return nil, fmt.Errorf("passphrase buffer is nil")
} }
// Get the passphrase string temporarily // Create recipient directly from passphrase - unavoidable string conversion due to age API
passphraseStr := passphrase.String() recipient, err := age.NewScryptRecipient(passphrase.String())
recipient, err := age.NewScryptRecipient(passphraseStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err) return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
} }
// Create a secure buffer for the data return EncryptToRecipient(data, recipient)
dataBuffer := memguard.NewBufferFromBytes(data)
defer dataBuffer.Destroy()
return EncryptToRecipient(dataBuffer, recipient)
} }
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption // DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
// The passphrase parameter should be a LockedBuffer for secure memory handling // The passphrase parameter should be a LockedBuffer for secure memory handling
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) { func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
if passphrase == nil { if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil") return nil, fmt.Errorf("passphrase buffer is nil")
} }
// Get the passphrase string temporarily // Create identity directly from passphrase - unavoidable string conversion due to age API
passphraseStr := passphrase.String() identity, err := age.NewScryptIdentity(passphrase.String())
identity, err := age.NewScryptIdentity(passphraseStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err) return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
} }

View File

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

View File

@ -0,0 +1,73 @@
//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

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

View File

@ -84,30 +84,22 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID()) Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase // Decrypt the unlocker private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer) privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil { if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID()) 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) return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
} }
defer privKeyBuffer.Destroy()
DebugWith("Successfully decrypted unlocker private key", DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()), slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)), slog.Int("decrypted_length", privKeyBuffer.Size()),
) )
// Parse the decrypted private key // Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID()) 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()) identity, err := age.ParseX25519Identity(privKeyBuffer.String())
if err != nil { if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID()) Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())

View File

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

View File

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

View File

@ -62,35 +62,8 @@ 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 // GetValue retrieves and decrypts the current version's value using the provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) { func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value", DebugWith("Getting secret value",
slog.String("secret_name", s.Name), slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()), slog.String("vault_name", s.vault.GetName()),
@ -206,16 +179,17 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
// Decrypt the encrypted long-term private key using the unlocker // Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name) Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity) ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name) 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) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
defer ltPrivKeyBuffer.Destroy()
// Parse the long-term private key // Parse the long-term private key
Debug("Parsing long-term private key", "secret_name", s.Name) Debug("Parsing long-term private key", "secret_name", s.Name)
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil { if err != nil {
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name) Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)

View File

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

View File

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

View File

@ -204,6 +204,12 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
if err := newVersion.Save(value); err != nil { if err := newVersion.Save(value); err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName) 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) return fmt.Errorf("failed to save version: %w", err)
} }
@ -259,13 +265,14 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
} }
// Decrypt version private key using long-term key // Decrypt version private key using long-term key
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity) versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil { if err != nil {
return fmt.Errorf("failed to decrypt version private key: %w", err) return fmt.Errorf("failed to decrypt version private key: %w", err)
} }
defer versionPrivKeyBuffer.Destroy()
// Parse version private key // Parse version private key
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData)) versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil { if err != nil {
return fmt.Errorf("failed to parse version private key: %w", err) return fmt.Errorf("failed to parse version private key: %w", err)
} }
@ -393,21 +400,26 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
return nil, fmt.Errorf("failed to decrypt version: %w", err) 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", secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name), slog.String("secret_name", name),
slog.String("version", version), slog.String("version", version),
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(decryptedValue)), slog.Int("decrypted_length", len(result)),
) )
// Debug: Log metadata about the decrypted value without exposing the actual secret // Debug: Log metadata about the decrypted value without exposing the actual secret
secret.Debug("Vault secret decryption debug info", secret.Debug("Vault secret decryption debug info",
"secret_name", name, "secret_name", name,
"version", version, "version", version,
"decrypted_value_length", len(decryptedValue), "decrypted_value_length", len(result),
"is_empty", len(decryptedValue) == 0) "is_empty", len(result) == 0)
return decryptedValue, nil return result, nil
} }
// UnlockVault unlocks the vault and returns the long-term private key // UnlockVault unlocks the vault and returns the long-term private key

View File

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

View File

@ -157,22 +157,23 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// Decrypt long-term private key using unlocker // Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType()) secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity) ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil { if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType()) 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) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
defer ltPrivKeyBuffer.Destroy()
secret.DebugWith("Successfully decrypted long-term private key", secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", len(ltPrivKeyData)), slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
) )
// Parse long-term private key // Parse long-term private key
secret.Debug("Parsing long-term private key", "vault_name", v.Name) secret.Debug("Parsing long-term private key", "vault_name", v.Name)
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil { if err != nil {
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name) secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
@ -207,3 +208,48 @@ func (v *Vault) GetName() string {
func (v *Vault) GetFilesystem() afero.Fs { func (v *Vault) GetFilesystem() afero.Fs {
return v.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

@ -0,0 +1,87 @@
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")
}