Compare commits

..

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

45 changed files with 845 additions and 3424 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -64,14 +64,6 @@ linters-settings:
nlreturn: nlreturn:
block-size: 2 block-size: 2
revive:
rules:
- name: var-naming
arguments:
- []
- []
- "upperCaseConst=true"
tagliatelle: tagliatelle:
case: case:
rules: rules:
@ -97,32 +89,3 @@ 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

@ -1,4 +1,8 @@
# IMPORTANT RULES # Rules
Read the rules in AGENTS.md and follow them.
# Memory
* Claude is an inanimate tool. The spam that Claude attempts to insert into * Claude is an inanimate tool. The spam that Claude attempts to insert into
commit messages (which it erroneously refers to as "attribution") is not commit messages (which it erroneously refers to as "attribution") is not
@ -12,84 +16,13 @@
* Code should always be formatted before committing. Do not commit * Code should always be formatted before committing. Do not commit
unformatted code. unformatted code.
* Code should always be linted and linter errors fixed before committing. * Code should always be linted before committing. Do not commit
NEVER commit code that does not pass the linter. DO NOT modify the linter unlinted code.
config unless specifically instructed.
* The test suite is fast and local. When running tests, NEVER run * The test suite is fast and local. When running tests, don't run
individual parts of the test suite, always run the whole thing by running individual parts of the test suite, always run the whole thing by running
"make test". "make test".
* 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.
* Do not create additional files in the root directory of the project
without asking permission first. Configuration files, documentation, and
build files are acceptable in the root, but source code and other files
should be organized in appropriate subdirectories.
* Do not use bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top of
the file) and give it a descriptive name, then use that constant in the
code instead of the bare string or number.
* If you are fixing a bug, write a test first that reproduces the bug and
fails, and then fix the bug in the code, using the test to verify that the
fix worked.
* When implementing new features, be aware of potential side-effects (such
as state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests when designing an
API.
* When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs
to see the time in a different timezone, store the user's timezone in a
separate field and convert the UTC time to the user's timezone when
displaying it. For internal use and internal applications and
administrative purposes, always display UTC.
* When implementing programs, put the main.go in
./cmd/<program_name>/main.go and put the program's code in
./internal/<program_name>/. This allows for multiple programs to be
implemented in the same repository without cluttering the root directory.
main.go should simply import and call <program_name>.CLIEntry(). The
full implementation should be in ./internal/<program_name>/.
* When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there
is a bug in the test). This is cheating, and it is bad. You should only
be modifying the test if it is incorrect or if the test is no longer
relevant. In almost all cases, you should be fixing the code that is
being tested, or updating the tests to match a refactored implementation.
* Always write a `Makefile` with the default target being `test`, and with a
`fmt` target that formats the code. The `test` target should run all
tests in the project, and the `fmt` target should format the code. `test`
should also have a prerequisite target `lint` that should run any linters
that are configured for the project.
* After each completed bugfix or feature, the code must be committed. Do
all of the pre-commit checks (test, lint, fmt) before committing, of
course. After each commit, push to the remote.
* Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new
feature, write a test for it. You don't need to target complete coverage,
but you should at least test any new functionality you add.
* Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output
the structured logs in jsonl format. Use go's log/slog.
* You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out of
the ordinary happened, please explain it, but in the normal case where you
found and fixed the bug, or implemented the feature, there is no need for
the end-of-change summary.

View File

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

View File

@ -1,23 +1,15 @@
export CGO_ENABLED=1
export DOCKER_HOST := ssh://root@ber1app1.local
# Version information
VERSION := 0.1.0
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
default: check default: check
build: ./secret build: ./secret
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.* # Simple build (no code signing needed)
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go ./secret:
go build -v -o $@ cmd/secret/main.go
vet: vet:
go vet ./... go vet ./...
test: lint vet test:
go test ./... || go test -v ./... go test ./... || go test -v ./...
fmt: fmt:
@ -26,19 +18,9 @@ fmt:
lint: lint:
golangci-lint run --timeout 5m golangci-lint run --timeout 5m
check: build test # Check all code quality (build + vet + lint + unit tests)
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

210
README.md
View File

@ -1,40 +1,28 @@
# secret - Local Secret Manager # Secret - Hierarchical Secret Manager
secret is a command-line local secret manager that implements a hierarchical 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.
key architecture for storing and managing sensitive data. It supports
multiple vaults, various unlock mechanisms, and provides secure storage
using the `age` encryption library.
It could be used as password manager, but was not designed as such. I
created it to scratch an itch for a secure key/value store for replacing a
bunch of pgp-encrypted files in a directory structure.
## Core Architecture ## Core Architecture
### Three-Layer Key Hierarchy ### Three-Layer Key Hierarchy
Secret implements a three-layer key architecture: Secret implements a sophisticated three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
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, 3. **Version-specific Keys**: Per-version keys that encrypt individual secret values
supporting multiple authentication methods
3. **Version-specific Keys**: Per-version keys that encrypt individual
secret values
### Version Management ### Version Management
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
- Metadata (unencrypted) including creation time and validity period - Encrypted metadata including creation time and validity period
- Immutable value storage - Immutable value storage
- Atomic version switching via symlink updates - Atomic version switching via symlink updates
### Vault System ### Vault System
Vaults provide logical separation of secrets, each with its own long-term Vaults provide logical separation of secrets, each with its own long-term key and unlocker set. This allows for complete isolation between different contexts (work, personal, projects).
key and unlocker set. This allows for complete isolation between different
contexts (work, personal, projects).
## Installation ## Installation
@ -73,9 +61,7 @@ make build
### Initialization ### Initialization
#### `secret init` #### `secret init`
Initializes the secret manager with a default vault. Prompts for a BIP39 mnemonic phrase and creates the initial directory structure.
Initializes the secret manager with a default vault. Prompts for a BIP39
mnemonic phrase and creates the initial directory structure.
**Environment Variables:** **Environment Variables:**
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase - `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
@ -83,33 +69,18 @@ mnemonic phrase and creates the initial directory structure.
### Vault Management ### Vault Management
#### `secret vault list [--json]` / `secret vault ls` #### `secret vault list [--json]`
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.
#### `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]`
Adds a secret to the current vault. Reads the secret value from stdin. Adds a secret to the current vault. Reads the secret value from stdin.
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
@ -118,53 +89,26 @@ Adds a secret to the current vault. Reads the secret value from stdin.
- Examples: `database/password`, `api.key`, `ssh_private_key` - Examples: `database/password`, `api.key`, `ssh_private_key`
#### `secret get <secret-name> [--version <version>]` #### `secret get <secret-name> [--version <version>]`
Retrieves and outputs a secret value to stdout. Retrieves and outputs a secret value to stdout.
- `--version, -v`: Get a specific version (default: current) - `--version, -v`: Get a specific version (default: current)
#### `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 ls` #### `secret version list <secret-name>`
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`
Generates a cryptographically secure BIP39 mnemonic phrase. Generates a cryptographically secure BIP39 mnemonic phrase.
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]` #### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
Generates and stores a random secret. Generates and stores a random secret.
- `--length, -l`: Length of generated secret (default: 16) - `--length, -l`: Length of generated secret (default: 16)
- `--type, -t`: Type of secret (`base58`, `alnum`) - `--type, -t`: Type of secret (`base58`, `alnum`)
@ -172,55 +116,39 @@ Generates and stores a random secret.
### Unlocker Management ### Unlocker Management
#### `secret unlocker list [--json]` / `secret unlocker ls` #### `secret unlockers list [--json]`
Lists all unlockers in the current vault with their metadata. Lists all unlockers in the current vault with their metadata.
#### `secret unlocker add <type> [options]` #### `secret unlockers add <type> [options]`
Creates a new unlocker of the specified type: 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 (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑 #### `secret unlockers rm <unlocker-id>`
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.
### Import Operations ### Import Operations
#### `secret import <secret-name> --source <filename>` #### `secret import <secret-name> --source <filename>`
Imports a secret from a file and stores it in the current vault under the given name. Imports a secret from a file and stores it in the current vault under the given name.
#### `secret vault import [vault-name]` #### `secret vault import [vault-name]`
Imports a mnemonic phrase into the specified vault (defaults to "default"). Imports a mnemonic phrase into the specified vault (defaults to "default").
### Encryption Operations ### Encryption Operations
#### `secret encrypt <secret-name> [--input=file] [--output=file]` #### `secret encrypt <secret-name> [--input=file] [--output=file]`
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key. Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
#### `secret decrypt <secret-name> [--input=file] [--output=file]` #### `secret decrypt <secret-name> [--input=file] [--output=file]`
Decrypts data using an Age key stored as a secret. Decrypts data using an Age key stored as a secret.
## Storage Architecture ## Storage Architecture
@ -241,7 +169,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.json # Unencrypted metadata │ │ │ │ │ │ └── metadata.age # Encrypted 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
@ -261,13 +189,12 @@ Decrypts data using an Age key stored as a secret.
### Key Management and Encryption Flow ### Key Management and Encryption Flow
#### 1: Long-term Keys #### Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation - **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys - **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers - **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
#### 2: Unlockers #### Unlockers
Unlockers provide different authentication methods to access the long-term keys: Unlockers provide different authentication methods to access the long-term keys:
1. **Passphrase Unlockers**: 1. **Passphrase Unlockers**:
@ -280,23 +207,10 @@ 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.
#### 3: Secret-specific Keys #### Secret-specific Keys
- Each secret has its own encryption key pair
- Each secret version has its own encryption key pair
- Private key encrypted to the vault's long-term key - Private key encrypted to the vault's long-term key
- Provides forward secrecy and granular access control - Provides forward secrecy and granular access control
@ -310,33 +224,27 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
## Security Features ## Security Features
### Encryption ### Encryption
- Uses the [Age encryption library](https://age-encryption.org/) with X25519 keys
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
- All private keys are encrypted at rest - All private keys are encrypted at rest
- No plaintext secrets stored on disk - No plaintext secrets stored on disk
### Access Control ### Access Control
- Multiple authentication methods supported - Multiple authentication methods supported
- Hierarchical key architecture provides defense in depth
- Vault isolation prevents cross-contamination - Vault isolation prevents cross-contamination
### Forward Secrecy ### Forward Secrecy
- Per-version encryption keys limit exposure if compromised - Per-version encryption keys limit exposure if compromised
- Each version is independently encrypted - Each version is independently encrypted
- Long-term keys protected by multiple unlocker layers
- Historical versions remain encrypted with their original keys - Historical versions remain encrypted with their original keys
### 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 paid Apple Developer Program for
signed entitlements to access the SEP and doxxing myself to Apple)
## Examples ## Examples
### Basic Workflow ### Basic Workflow
```bash ```bash
# Initialize with a new mnemonic # Initialize with a new mnemonic
secret generate mnemonic # Copy the output secret generate mnemonic # Copy the output
@ -351,13 +259,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
```bash ```bash
# Create separate vaults for different contexts # Create separate vaults for different contexts
secret vault create work secret vault create work
@ -366,7 +270,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 unlocker add passphrase # Add passphrase authentication secret unlockers add passphrase # Add passphrase authentication
# Switch to personal vault # Switch to personal vault
secret vault select personal secret vault select personal
@ -374,43 +278,22 @@ 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 unlocker add passphrase # Password-based secret unlockers add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key secret unlockers add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
# List unlockers # List unlockers
secret unlocker list secret unlockers 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
```bash ```bash
# Generate an Age key and store it as a secret # Generate an Age key and store it as a secret
secret generate secret encryption/mykey secret generate secret encryption/mykey
@ -427,35 +310,33 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Cryptographic Primitives ### Cryptographic Primitives
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation - **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
- **Encryption**: Age (X25519 + ChaCha20-Poly1305) - **Encryption**: Age (X25519 + ChaCha20-Poly1305)
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
- **Authentication**: Poly1305 MAC - **Authentication**: Poly1305 MAC
- **Hashing**: Double SHA-256 for public key identification - **Hashing**: Double SHA-256 for public key identification
### File Formats ### File Formats
- **age Files**: Standard age encryption format (.age extension) - **Age Files**: Standard Age encryption format (.age extension)
- **Metadata**: Unencrypted JSON format with timestamps and type information - **Metadata**: JSON format with timestamps and type information
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash - **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
### Vault Management ### Vault Management
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic - **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
- **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)
## Security Considerations ## Security Considerations
### Threat Model ### Threat Model
- Protects against unauthorized access to secret values - Protects against unauthorized access to secret values
- Provides defense against compromise of individual components - Provides defense against compromise of individual components
- Supports hardware-backed authentication where available - Supports hardware-backed authentication where available
### Best Practices ### Best Practices
1. Use strong, unique passphrases for unlockers 1. Use strong, unique passphrases for unlockers
2. Enable hardware authentication (Keychain, hardware tokens) when available 2. Enable hardware authentication (Keychain, hardware tokens) when available
3. Regularly audit unlockers and remove unused ones 3. Regularly audit unlockers and remove unused ones
@ -463,7 +344,6 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
5. Use separate vaults for different security contexts 5. Use separate vaults for different security contexts
### Limitations ### Limitations
- Requires access to unlockers for secret retrieval - Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up - Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms - Hardware features limited to supported platforms
@ -487,21 +367,9 @@ go test -tags=integration -v ./internal/cli # Integration tests
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers - **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers
- **Vault Isolation**: Complete separation between different vaults - **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,51 +4,6 @@ This document outlines the bugs, issues, and improvements that need to be
addressed before the 1.0 release of the secret manager. Items are 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

View File

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

5
go.mod
View File

@ -9,7 +9,6 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/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
@ -24,11 +23,7 @@ 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,10 +43,6 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 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=
@ -67,14 +63,7 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags 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=
@ -128,8 +117,6 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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,13 +2,21 @@
package cli package cli
import ( import (
"bufio"
"fmt" "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
@ -60,7 +68,32 @@ func (cli *Instance) GetStateDir() string {
return cli.stateDir return cli.stateDir
} }
// Print outputs to the command's configured output writer // getStdinScanner returns a shared scanner for stdin to avoid buffering issues
func (cli *Instance) Print(a ...interface{}) (n int, err error) { func getStdinScanner() *bufio.Scanner {
return fmt.Fprint(cli.cmd.OutOrStdout(), a...) if stdinScanner == nil {
stdinScanner = bufio.NewScanner(os.Stdin)
}
return stdinScanner
}
// readLineFromStdin reads a single line from stdin with a prompt
// Uses a shared scanner to avoid buffering issues between multiple calls
func readLineFromStdin(prompt string) (string, error) {
// Check if stderr is a terminal - if not, we can't prompt interactively
if !term.IsTerminal(syscall.Stderr) {
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
}
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
scanner := getStdinScanner()
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
return "", fmt.Errorf("failed to read from stdin: EOF")
}
return strings.TrimSpace(scanner.Text()), nil
} }

View File

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

View File

@ -1,199 +0,0 @@
package cli
import (
"encoding/json"
"path/filepath"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// getSecretNamesCompletionFunc returns a completion function that provides secret names
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get list of secrets
secrets, err := vlt.ListSecrets()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Filter secrets based on what user has typed
var completions []string
for _, secret := range secrets {
if strings.HasPrefix(secret, toComplete) {
completions = append(completions, secret)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get unlocker metadata list
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
continue
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
// Create the appropriate unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
}
if unlocker != nil {
id := unlocker.GetID()
if strings.HasPrefix(id, toComplete) {
completions = append(completions, id)
}
}
break
}
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getVaultNamesCompletionFunc returns a completion function that provides vault names
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
vaults, err := vault.ListVaults(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []string
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
func getVaultSecretCompletionFunc(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) {
var completions []string
// Check if we're completing after a vault: prefix
if strings.Contains(toComplete, ":") {
// Complete secret names for the specified vault
const vaultSecretParts = 2
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
vaultName := parts[0]
secretPrefix := parts[1]
vlt := vault.NewVault(fs, stateDir, vaultName)
secrets, err := vlt.ListSecrets()
if err == nil {
for _, secretName := range secrets {
if strings.HasPrefix(secretName, secretPrefix) {
completions = append(completions, vaultName+":"+secretName)
}
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Complete vault names with ":" suffix
vaults, err := vault.ListVaults(fs, stateDir)
if err == nil {
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v+":")
}
}
}
// Also complete secrets from current vault (for within-vault moves)
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
secrets, err := currentVlt.ListSecrets()
if err == nil {
for _, secretName := range secrets {
if strings.HasPrefix(secretName, toComplete) {
completions = append(completions, secretName)
}
}
}
}
return completions, cobra.ShellCompDirectiveNoSpace
}
}

View File

@ -96,13 +96,21 @@ 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
secretBuffer, err := cli.getSecretValue(vlt, secretObj) secretValue, 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()
ageSecretKey = secretBuffer.String() // Create secure buffer for the secret value
secureBuffer := memguard.NewBufferFromBytes(secretValue)
defer secureBuffer.Destroy()
// Clear the original secret value
for i := range secretValue {
secretValue[i] = 0
}
ageSecretKey = secureBuffer.String()
// Validate that it's a valid age secret key // Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) { if !isValidAgeSecretKey(ageSecretKey) {
@ -181,28 +189,36 @@ 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 secretBuffer *memguard.LockedBuffer var secretValue []byte
if os.Getenv(secret.EnvMnemonic) != "" { if os.Getenv(secret.EnvMnemonic) != "" {
secretBuffer, err = secretObj.GetValue(nil) secretValue, 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)
} }
secretBuffer, err = secretObj.GetValue(unlocker) secretValue, 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(secretBuffer.String()) { if !isValidAgeSecretKey(secureBuffer.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(secretBuffer.String()) identity, err := age.ParseX25519Identity(secureBuffer.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)
} }
@ -250,7 +266,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) (*memguard.LockedBuffer, error) { func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
if os.Getenv(secret.EnvMnemonic) != "" { if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil) return secretObj.GetValue(nil)
} }

View File

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

View File

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

View File

@ -60,17 +60,14 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
mnemonicStr = envMnemonic mnemonicStr = envMnemonic
} else { } else {
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo // Read mnemonic from stdin using shared line reader
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ") var err error
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 == "" {
@ -205,26 +202,20 @@ 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")
} }
// Clean up the second buffer, we'll return the first // Create a new buffer with the confirmed passphrase
passphraseBuffer2.Destroy() return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
// Return the first buffer (caller is responsible for destroying it)
return passphraseBuffer1, nil
} }

View File

@ -18,11 +18,6 @@ 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
@ -65,6 +60,7 @@ 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
@ -129,8 +125,7 @@ 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
// NOTE: Skipped because vault creation now includes mnemonic import test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// Test 5: Add secrets with versioning // Test 5: Add secrets with versioning
// Command: echo "password123" | secret add database/password // Command: echo "password123" | secret add database/password
@ -182,32 +177,14 @@ 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 12c: Cross-vault move
// Commands: secret move work:secret default, secret move work:secret default:newname
// Purpose: Test moving secrets between vaults with re-encryption
// Expected: Secret copied to destination vault with all versions, source deleted
test12cCrossVaultMove(t, testMnemonic, runSecretWithEnv, runSecretWithStdin)
// Test 13: Unlocker management // Test 13: Unlocker management
// Commands: secret unlocker list, secret unlocker add pgp // Commands: secret unlockers list, secret unlockers add pgp
// Purpose: Test multiple unlocker types // Purpose: Test multiple unlocker types
// Expected filesystem: // Expected filesystem:
// - Multiple directories under unlockers.d/ // - Multiple directories under unlockers.d/
@ -338,12 +315,16 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
defaultVaultDir := filepath.Join(vaultsDir, "default") defaultVaultDir := filepath.Join(vaultsDir, "default")
verifyFileExists(t, defaultVaultDir) verifyFileExists(t, defaultVaultDir)
// Check currentvault file contains the vault name // Check currentvault symlink - it may be absolute or relative
currentVaultFile := filepath.Join(tempDir, "currentvault") currentVaultLink := filepath.Join(tempDir, "currentvault")
targetBytes, err := os.ReadFile(currentVaultFile) target, err := os.Readlink(currentVaultLink)
require.NoError(t, err, "should be able to read currentvault file") require.NoError(t, err, "should be able to read currentvault symlink")
target := string(targetBytes) // Check if it points to the right place (handle both absolute and relative)
assert.Equal(t, "default", target, "currentvault should contain vault name") if filepath.IsAbs(target) {
assert.Equal(t, filepath.Join(tempDir, "vaults.d/default"), target)
} else {
assert.Equal(t, "vaults.d/default", target)
}
// Verify vault structure // Verify vault structure
pubKeyFile := filepath.Join(defaultVaultDir, "pub.age") pubKeyFile := filepath.Join(defaultVaultDir, "pub.age")
@ -368,12 +349,22 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age") encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey) verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file contains the relative path // Check current-unlocker file
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker") currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile) verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile) // Read the current-unlocker symlink to see what it points to
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type") symlinkTarget, err := os.Readlink(currentUnlockerFile)
if err != nil {
t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
// Fallback to reading as file if it's not a symlink
currentUnlockerContent := readFile(t, currentUnlockerFile)
t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent))
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
} else {
t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
}
// Verify vault-metadata.json in vault // Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json") vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
@ -449,12 +440,6 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
} }
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) { 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")
@ -464,12 +449,17 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
workVaultDir := filepath.Join(tempDir, "vaults.d", "work") workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
verifyFileExists(t, workVaultDir) verifyFileExists(t, workVaultDir)
// Check currentvault file was updated // Check currentvault symlink was updated
currentVaultFile := filepath.Join(tempDir, "currentvault") currentVaultLink := filepath.Join(tempDir, "currentvault")
targetBytes, err := os.ReadFile(currentVaultFile) target, err := os.Readlink(currentVaultLink)
require.NoError(t, err, "should be able to read currentvault file") require.NoError(t, err, "should be able to read currentvault symlink")
target := string(targetBytes)
assert.Equal(t, "work", target, "currentvault should contain vault name") // The symlink should now point to work vault
if filepath.IsAbs(target) {
assert.Equal(t, filepath.Join(tempDir, "vaults.d/work"), target)
} else {
assert.Equal(t, "vaults.d/work", target)
}
// Verify work vault has basic structure // Verify work vault has basic structure
unlockersDir := filepath.Join(workVaultDir, "unlockers.d") unlockersDir := filepath.Join(workVaultDir, "unlockers.d")
@ -478,9 +468,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 has a long-term key (mnemonic was provided) // Verify that work vault does NOT have a long-term key yet (no mnemonic imported)
pubKeyFile := filepath.Join(workVaultDir, "pub.age") pubKeyFile := filepath.Join(workVaultDir, "pub.age")
verifyFileExists(t, pubKeyFile) verifyFileNotExists(t, pubKeyFile)
// List vaults to verify both exist // List vaults to verify both exist
output, err = runSecret("vault", "list") output, err = runSecret("vault", "list")
@ -595,15 +585,15 @@ func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(
metadataFile := filepath.Join(versionDir, "metadata.age") metadataFile := filepath.Join(versionDir, "metadata.age")
verifyFileExists(t, metadataFile) verifyFileExists(t, metadataFile)
// Check current file // Check current symlink
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
verifyFileExists(t, currentLink) verifyFileExists(t, currentLink)
// Verify current file contains the version name // Verify symlink points to the version directory
targetBytes, err := os.ReadFile(currentLink) target, err := os.Readlink(currentLink)
require.NoError(t, err, "should read current file") require.NoError(t, err, "should read current symlink")
target := string(targetBytes) expectedTarget := filepath.Join("versions", versionName)
assert.Equal(t, versionName, target, "current file should contain version name") assert.Equal(t, expectedTarget, target, "current symlink should point to version")
// Verify we can retrieve the secret // Verify we can retrieve the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -685,12 +675,12 @@ func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecre
verifyFileExists(t, filepath.Join(versionDir, "metadata.age")) verifyFileExists(t, filepath.Join(versionDir, "metadata.age"))
} }
// Check current file points to new version // Check current symlink points to new version
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
targetBytes, err := os.ReadFile(currentLink) target, err := os.Readlink(currentLink)
require.NoError(t, err, "should read current file") require.NoError(t, err, "should read current symlink")
target := string(targetBytes) expectedTarget := filepath.Join("versions", newVersion)
assert.Equal(t, newVersion, target, "current file should contain version name") assert.Equal(t, expectedTarget, target, "current symlink should point to new version")
// Verify we get the new value when retrieving the secret // Verify we get the new value when retrieving the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -802,10 +792,9 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
// Before promotion, current should point to .002 (from test 07) // Before promotion, current should point to .002 (from test 07)
currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current") currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current")
targetBytes, err := os.ReadFile(currentLink) target, err := os.Readlink(currentLink)
require.NoError(t, err, "should read current file") require.NoError(t, err, "should read current symlink")
target := string(targetBytes) assert.Equal(t, filepath.Join("versions", version002), target, "current should initially point to .002")
assert.Equal(t, version002, target, "current should initially point to .002")
// Promote the old version // Promote the old version
output, err := runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
@ -816,11 +805,11 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
assert.Contains(t, output, "Promoted version", "should confirm promotion") assert.Contains(t, output, "Promoted version", "should confirm promotion")
assert.Contains(t, output, version001, "should mention the promoted version") assert.Contains(t, output, version001, "should mention the promoted version")
// Verify current file was updated // Verify symlink was updated
newTargetBytes, err := os.ReadFile(currentLink) newTarget, err := os.Readlink(currentLink)
require.NoError(t, err, "should read current file after promotion") require.NoError(t, err, "should read current symlink after promotion")
newTarget := string(newTargetBytes) expectedTarget := filepath.Join("versions", version001)
assert.Equal(t, version001, newTarget, "current file should now point to .001") assert.Equal(t, expectedTarget, newTarget, "current symlink should now point to .001")
// Verify we now get the old value when retrieving the secret // Verify we now get the old value when retrieving the secret
getOutput, err := runSecretWithEnv(map[string]string{ getOutput, err := runSecretWithEnv(map[string]string{
@ -912,81 +901,6 @@ 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) {
@ -1096,172 +1010,15 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
} }
} }
func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
// First, create a secret to move
_, err := runSecretWithStdin("original-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/original")
require.NoError(t, err, "add test/original should succeed")
// Test move command
output, err := runSecret("move", "test/original", "test/renamed")
require.NoError(t, err, "move should succeed")
assert.Contains(t, output, "Moved secret 'test/original' to 'test/renamed'", "should show move confirmation")
// Need to create a runSecretWithEnv for get operations
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
return cli.ExecuteCommandInProcess(args, "", env)
}
// Verify original doesn't exist
_, err = runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/original")
assert.Error(t, err, "get original should fail after move")
// Verify new location exists and has correct value
getOutput, err := runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/renamed")
require.NoError(t, err, "get renamed should succeed")
assert.Equal(t, "original-value", getOutput, "renamed secret should have original value")
// Test mv alias
_, err = runSecretWithStdin("another-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/another")
require.NoError(t, err, "add test/another should succeed")
output, err = runSecret("mv", "test/another", "test/moved-with-mv")
require.NoError(t, err, "mv alias should work")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Test rename alias
_, err = runSecretWithStdin("rename-test-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/rename-me")
require.NoError(t, err, "add test/rename-me should succeed")
output, err = runSecret("rename", "test/rename-me", "test/renamed-with-alias")
require.NoError(t, err, "rename alias should work")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Test error cases
// Try to move non-existent secret
output, err = runSecret("move", "test/nonexistent", "test/destination")
assert.Error(t, err, "move non-existent should fail")
assert.Contains(t, output, "not found", "should indicate source not found")
// Try to move to existing destination
_, err = runSecretWithStdin("dest-value", map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "add", "test/existing-dest")
require.NoError(t, err, "add test/existing-dest should succeed")
output, err = runSecret("move", "test/renamed", "test/existing-dest")
assert.Error(t, err, "move to existing destination should fail")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Verify the source wasn't removed since move failed
getOutput, err = runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", "test/renamed")
require.NoError(t, err, "get source should still work after failed move")
assert.Equal(t, "original-value", getOutput, "source should still have original value")
}
func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
env := map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}
// Create a test secret in the work vault
_, err := runSecretWithEnv(env, "vault", "select", "work")
require.NoError(t, err, "select work vault should succeed")
// Add a secret with a version
_, err = runSecretWithStdin("cross-vault-value-v1", env, "add", "cross/move/test")
require.NoError(t, err, "add cross/move/test should succeed")
// Add another version
_, err = runSecretWithStdin("cross-vault-value-v2", env, "add", "--force", "cross/move/test")
require.NoError(t, err, "add cross/move/test v2 should succeed")
// Move to default vault using cross-vault syntax
output, err := runSecretWithEnv(env, "move", "work:cross/move/test", "default")
require.NoError(t, err, "cross-vault move should succeed")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
assert.Contains(t, output, "2 version(s)", "should show version count")
// Verify secret exists in default vault
_, err = runSecretWithEnv(env, "vault", "select", "default")
require.NoError(t, err, "select default vault should succeed")
value, err := runSecretWithEnv(env, "get", "cross/move/test")
require.NoError(t, err, "get from default vault should succeed")
assert.Equal(t, "cross-vault-value-v2", value, "should have latest version value")
// Verify secret no longer exists in work vault
_, err = runSecretWithEnv(env, "vault", "select", "work")
require.NoError(t, err, "select work vault should succeed")
_, err = runSecretWithEnv(env, "get", "cross/move/test")
assert.Error(t, err, "get from work vault should fail after move")
// Test cross-vault move with rename
_, err = runSecretWithStdin("rename-test", env, "add", "rename/source")
require.NoError(t, err, "add rename/source should succeed")
output, err = runSecretWithEnv(env, "move", "work:rename/source", "default:renamed/dest")
require.NoError(t, err, "cross-vault move with rename should succeed")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Verify renamed secret exists in default vault
_, err = runSecretWithEnv(env, "vault", "select", "default")
require.NoError(t, err, "select default vault should succeed")
value, err = runSecretWithEnv(env, "get", "renamed/dest")
require.NoError(t, err, "get renamed secret should succeed")
assert.Equal(t, "rename-test", value, "should have correct value")
// Test --force flag for overwriting
_, err = runSecretWithStdin("existing-secret", env, "add", "force/test")
require.NoError(t, err, "add force/test in default should succeed")
_, err = runSecretWithEnv(env, "vault", "select", "work")
require.NoError(t, err, "select work vault should succeed")
_, err = runSecretWithStdin("new-value", env, "add", "force/test")
require.NoError(t, err, "add force/test in work should succeed")
// Move without force should fail
output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
assert.Error(t, err, "move without force should fail when dest exists")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Move with force should succeed
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
require.NoError(t, err, "move with force should succeed")
assert.Contains(t, output, "Moved secret", "should show move confirmation")
// Verify value was overwritten
_, err = runSecretWithEnv(env, "vault", "select", "default")
require.NoError(t, err, "select default vault should succeed")
value, err = runSecretWithEnv(env, "get", "force/test")
require.NoError(t, err, "get overwritten secret should succeed")
assert.Equal(t, "new-value", value, "should have new value after force move")
}
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("unlocker", "list") output, err := runSecret("unlockers", "list")
require.NoError(t, err, "unlocker list should succeed") require.NoError(t, err, "unlockers list should succeed")
t.Logf("DEBUG: unlocker list output: %q", output) t.Logf("DEBUG: unlockers 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")
@ -1270,15 +1027,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
}, "unlocker", "add", "passphrase") }, "unlockers", "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("unlocker", "list") output, err = runSecret("unlockers", "list")
require.NoError(t, err, "unlocker list should succeed") require.NoError(t, err, "unlockers list should succeed")
// Count passphrase unlockers // Count passphrase unlockers
lines := strings.Split(output, "\n") lines := strings.Split(output, "\n")
@ -1294,8 +1051,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("unlocker", "list", "--json") jsonOutput, err := runSecret("unlockers", "list", "--json")
require.NoError(t, err, "unlocker list --json should succeed") require.NoError(t, err, "unlockers 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)
@ -1322,21 +1079,27 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Verify current vault is default // Verify current vault is default
currentVaultFile := filepath.Join(tempDir, "currentvault") currentVaultLink := filepath.Join(tempDir, "currentvault")
targetBytes, err := os.ReadFile(currentVaultFile) target, err := os.Readlink(currentVaultLink)
require.NoError(t, err, "should read currentvault file") require.NoError(t, err, "should read currentvault symlink")
target := string(targetBytes) if filepath.IsAbs(target) {
assert.Equal(t, "default", target, "currentvault should contain vault name") assert.Contains(t, target, "vaults.d/default")
} else {
assert.Contains(t, target, "default")
}
// Switch to work vault // Switch to work vault
_, err = runSecret("vault", "select", "work") _, err = runSecret("vault", "select", "work")
require.NoError(t, err, "vault select work should succeed") require.NoError(t, err, "vault select work should succeed")
// Verify current vault is now work // Verify current vault is now work
targetBytes, err = os.ReadFile(currentVaultFile) target, err = os.Readlink(currentVaultLink)
require.NoError(t, err, "should read currentvault file") require.NoError(t, err, "should read currentvault symlink")
target = string(targetBytes) if filepath.IsAbs(target) {
assert.Equal(t, "work", target, "currentvault should contain vault name") assert.Contains(t, target, "vaults.d/work")
} else {
assert.Contains(t, target, "work")
}
// Switch back to default // Switch back to default
_, err = runSecret("vault", "select", "default") _, err = runSecret("vault", "select", "default")
@ -1773,10 +1536,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 unlocker list --json (already tested in test 13) // Test unlockers list --json (already tested in test 13)
// All JSON outputs verified to be valid and contain expected fields // All JSON outputs verified to be valid and contain expected fields
t.Log("JSON output formats verified for vault list, secret list, and unlocker list") t.Log("JSON output formats verified for vault list, secret list, and unlockers list")
} }
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@ -2071,28 +1834,26 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
} }
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) { func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
// Test currentvault file // Test currentvault symlink
currentVaultFile := filepath.Join(tempDir, "currentvault") currentVaultLink := filepath.Join(tempDir, "currentvault")
verifyFileExists(t, currentVaultFile) verifyFileExists(t, currentVaultLink)
// Read the file - should contain just the vault name // Read the symlink
targetBytes, err := os.ReadFile(currentVaultFile) target, err := os.Readlink(currentVaultLink)
require.NoError(t, err, "should read currentvault file") require.NoError(t, err, "should read currentvault symlink")
target := string(targetBytes) assert.Contains(t, target, "vaults.d", "should point to vaults.d directory")
assert.NotContains(t, target, "/", "should be bare vault name without path")
// Test version current file // Test version current symlink
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default") defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password") secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password")
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
verifyFileExists(t, currentLink) verifyFileExists(t, currentLink)
targetBytes, err = os.ReadFile(currentLink) target, err = os.Readlink(currentLink)
require.NoError(t, err, "should read current version file") require.NoError(t, err, "should read current version symlink")
target = string(targetBytes) assert.Contains(t, target, "versions", "should point to versions directory")
assert.NotContains(t, target, "/", "should be bare version name without path")
// Test that current file updates properly // Test that symlinks update properly
// Add new version // Add new version
cmd := exec.Command(secretPath, "add", "database/password", "--force") cmd := exec.Command(secretPath, "add", "database/password", "--force")
cmd.Env = []string{ cmd.Env = []string{
@ -2105,12 +1866,11 @@ func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic strin
_, err = cmd.CombinedOutput() _, err = cmd.CombinedOutput()
require.NoError(t, err, "add new version should succeed") require.NoError(t, err, "add new version should succeed")
// Check that current file was updated // Check that symlink was updated
newTargetBytes, err := os.ReadFile(currentLink) newTarget, err := os.Readlink(currentLink)
require.NoError(t, err, "should read updated current file") require.NoError(t, err, "should read updated symlink")
newTarget := string(newTargetBytes) assert.NotEqual(t, target, newTarget, "symlink should point to new version")
assert.NotEqual(t, target, newTarget, "current file should point to new version") assert.Contains(t, newTarget, "versions", "new symlink should still point to versions directory")
assert.NotContains(t, newTarget, "/", "new current file should be bare version name")
} }
func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@ -2146,11 +1906,18 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d")) err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d"))
require.NoError(t, err, "backup vaults should succeed") require.NoError(t, err, "backup vaults should succeed")
// Also backup the currentvault file // Also backup the currentvault symlink/file
currentVaultSrc := filepath.Join(tempDir, "currentvault") currentVaultSrc := filepath.Join(tempDir, "currentvault")
currentVaultDst := filepath.Join(backupDir, "currentvault") currentVaultDst := filepath.Join(backupDir, "currentvault")
data := readFile(t, currentVaultSrc) if target, err := os.Readlink(currentVaultSrc); err == nil {
writeFile(t, currentVaultDst, data) // It's a symlink, recreate it
err = os.Symlink(target, currentVaultDst)
require.NoError(t, err, "backup currentvault symlink should succeed")
} else {
// It's a regular file, copy it
data := readFile(t, currentVaultSrc)
writeFile(t, currentVaultDst, data)
}
// Add more secrets after backup // Add more secrets after backup
cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force") cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force")
@ -2180,8 +1947,13 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
// Restore currentvault // Restore currentvault
os.Remove(currentVaultSrc) os.Remove(currentVaultSrc)
restoredData := readFile(t, currentVaultDst) if target, err := os.Readlink(currentVaultDst); err == nil {
writeFile(t, currentVaultSrc, restoredData) err = os.Symlink(target, currentVaultSrc)
require.NoError(t, err, "restore currentvault symlink should succeed")
} else {
data := readFile(t, currentVaultDst)
writeFile(t, currentVaultSrc, data)
}
// Verify original secrets are restored // Verify original secrets are restored
output, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
@ -2323,7 +2095,18 @@ func copyDir(src, dst string) error {
srcPath := filepath.Join(src, entry.Name()) srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name()) dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() { // Check if it's a symlink
if info, err := os.Lstat(srcPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
// It's a symlink - read and recreate it
target, err := os.Readlink(srcPath)
if err != nil {
return err
}
err = os.Symlink(target, dstPath)
if err != nil {
return err
}
} else if entry.IsDir() {
err = copyDir(srcPath, dstPath) err = copyDir(srcPath, dstPath)
if err != nil { if err != nil {
return err return err

View File

@ -34,15 +34,12 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd()) cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd()) cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd()) cmd.AddCommand(newListCmd())
cmd.AddCommand(newRemoveCmd()) cmd.AddCommand(newUnlockersCmd())
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,35 +4,14 @@ 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"
) )
const (
// vaultSecretSeparator is the delimiter between vault name and secret name
vaultSecretSeparator = ":"
// vaultSecretParts is the number of parts when splitting vault:secret
vaultSecretParts = 2
)
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
// Returns (vaultName, secretName, isQualified)
// If no vault is specified, returns empty vaultName and isQualified=false
func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) {
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
if len(parts) == vaultSecretParts {
return parts[0], parts[1], true
}
return "", ref, false
}
func newAddCmd() *cobra.Command { func newAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add <secret-name>", Use: "add <secret-name>",
@ -58,12 +37,10 @@ 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()
@ -86,7 +63,6 @@ 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 {
@ -95,12 +71,11 @@ func newListCmd() *cobra.Command {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter) return cli.ListSecrets(cmd, jsonOutput, filter)
}, },
} }
cmd.Flags().Bool("json", false, "Output in JSON format") cmd.Flags().Bool("json", false, "Output in JSON format")
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
return cmd return cmd
} }
@ -128,61 +103,6 @@ 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 a secret within a vault or between vaults.
For within-vault moves (rename):
secret move old-name new-name
For cross-vault moves:
secret move source-vault:secret-name dest-vault
secret move source-vault:secret-name dest-vault:new-name
Cross-vault moves copy ALL versions of the secret, preserving history.
The source secret is deleted after successful copy.`,
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) {
// Complete vault:secret format
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
},
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
return cli.MoveSecret(cmd, args[0], args[1], force)
},
}
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
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++
@ -305,9 +225,6 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error { func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version) secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
// Store the command for output
cli.cmd = cmd
// 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 {
@ -332,8 +249,8 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
secret.Debug("Got secret value", "valueLength", len(value)) secret.Debug("Got secret value", "valueLength", len(value))
// Print the secret value to stdout // Print the secret value to stdout
_, _ = cli.Print(string(value)) cmd.Print(string(value))
secret.Debug("Printed value to stdout") secret.Debug("Printed value to cmd")
// Debug: Log what we're actually printing // Debug: Log what we're actually printing
secret.Debug("Secret retrieval debug info", secret.Debug("Secret retrieval debug info",
@ -347,7 +264,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, quietOutput bool, filter string) error { func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput 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 {
@ -403,21 +320,15 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
return fmt.Errorf("failed to marshal JSON: %w", err) return fmt.Errorf("failed to marshal JSON: %w", err)
} }
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes)) cmd.Println(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 != "" {
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
} else { } else {
_, _ = fmt.Fprintln(out, "No secrets found in current vault.") cmd.Println("No secrets found in current vault.")
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.") cmd.Println("Run 'secret add <name>' to create one.")
} }
return nil return nil
@ -425,25 +336,12 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
// Get current vault name for display // Get current vault name for display
if filter != "" { if filter != "" {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
} else { } else {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName()) cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
} }
cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
// Calculate the maximum name length for proper column alignment cmd.Printf("%-40s %-20s\n", "----", "------------")
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"
@ -451,14 +349,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
metadata := secretObj.GetMetadata() metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
} }
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated) cmd.Printf("%-40s %-20s\n", secretName, lastUpdated)
} }
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets)) cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
if filter != "" { if filter != "" {
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets)) cmd.Printf(" (filtered from %d)", len(secrets))
} }
_, _ = fmt.Fprintln(out) cmd.Println()
} }
return nil return nil
@ -550,231 +448,3 @@ 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 (within or across vaults)
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
// Parse source and destination
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
// If neither is qualified, this is a simple within-vault rename
if !srcQualified && !destQualified {
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
}
// Cross-vault move requires source to be qualified
if !srcQualified {
return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move")
}
// If destination is not qualified (no colon), check if it's a vault name
// Format: "work:secret default" means move to vault "default"
// Format: "work:secret default:newname" means move to vault "default" with new name
if !destQualified {
// Check if dest is actually a vault name
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err == nil {
for _, v := range vaults {
if v == dest {
// dest is a vault name, use source secret name
destVaultName = dest
destSecretName = srcSecretName
break
}
}
}
// If destVaultName is still empty, dest is a secret name in source vault
if destVaultName == "" {
destVaultName = srcVaultName
destSecretName = dest
}
}
// If destination secret name is empty, use source secret name
if destSecretName == "" {
destSecretName = srcSecretName
}
// Same vault? Use simple rename if possible (optimization)
if srcVaultName == destVaultName {
// Select the vault and do a simple move
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
}
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
}
// Cross-vault move
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
}
// moveSecretWithinVault handles rename within the current vault
func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vaultDir, err := currentVlt.GetDirectory()
if err != nil {
return err
}
sourceEncoded := strings.ReplaceAll(source, "/", "%")
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", source)
}
destEncoded := strings.ReplaceAll(dest, "/", "%")
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 {
if !force {
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
}
if err := cli.fs.RemoveAll(destDir); err != nil {
return fmt.Errorf("failed to remove existing destination: %w", err)
}
}
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", source, dest)
return nil
}
// moveSecretCrossVault handles moving between different vaults
func (cli *Instance) moveSecretCrossVault(
cmd *cobra.Command,
srcVaultName, srcSecretName,
destVaultName, destSecretName string,
force bool,
) error {
// Get source vault
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
// Verify source vault exists
exists, err := afero.DirExists(cli.fs, srcVaultDir)
if err != nil || !exists {
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
}
// Verify source secret exists
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
exists, err = afero.DirExists(cli.fs, srcSecretDir)
if err != nil || !exists {
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
}
// Get destination vault
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
destVaultDir, err := destVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Verify destination vault exists
exists, err = afero.DirExists(cli.fs, destVaultDir)
if err != nil || !exists {
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
}
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
_, err = destVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
}
// Count versions for user feedback
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
versionCount := len(versions)
// Copy all versions
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
return err
}
// Delete source secret
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
// Copy succeeded but delete failed - warn but don't fail
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
return nil
}
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
return nil
}

View File

@ -74,60 +74,60 @@ func TestAddSecretVariousSizes(t *testing.T) {
// Set up test environment // Set up test environment
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := "/test/state" stateDir := "/test/state"
// Set test mnemonic // Set test mnemonic
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault // Create vault
vaultName := "test-vault" vaultName := "test-vault"
_, err := vault.CreateVault(fs, stateDir, vaultName) _, err := vault.CreateVault(fs, stateDir, vaultName)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName) vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600) err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
require.NoError(t, err) require.NoError(t, err)
// Get vault and set up long-term key // Get vault and set up long-term key
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0) ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
require.NoError(t, err) require.NoError(t, err)
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Generate test data of specified size // Generate test data of specified size
testData := make([]byte, tt.size) testData := make([]byte, tt.size)
_, err = rand.Read(testData) _, err = rand.Read(testData)
require.NoError(t, err) require.NoError(t, err)
// Add newline that will be stripped // Add newline that will be stripped
testDataWithNewline := append(testData, '\n') testDataWithNewline := append(testData, '\n')
// Create fake stdin // Create fake stdin
stdin := bytes.NewReader(testDataWithNewline) stdin := bytes.NewReader(testDataWithNewline)
// Create command with fake stdin // Create command with fake stdin
cmd := &cobra.Command{} cmd := &cobra.Command{}
cmd.SetIn(stdin) cmd.SetIn(stdin)
// Create CLI instance // Create CLI instance
cli := NewCLIInstance() cli := NewCLIInstance()
cli.fs = fs cli.fs = fs
cli.stateDir = stateDir cli.stateDir = stateDir
cli.cmd = cmd cli.cmd = cmd
// Test adding the secret // Test adding the secret
secretName := fmt.Sprintf("test-secret-%d", tt.size) secretName := fmt.Sprintf("test-secret-%d", tt.size)
err = cli.AddSecret(secretName, false) err = cli.AddSecret(secretName, false)
if tt.shouldError { if tt.shouldError {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg) assert.Contains(t, err.Error(), tt.errorMsg)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
@ -193,57 +193,57 @@ func TestImportSecretVariousSizes(t *testing.T) {
// Set up test environment // Set up test environment
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := "/test/state" stateDir := "/test/state"
// Set test mnemonic // Set test mnemonic
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault // Create vault
vaultName := "test-vault" vaultName := "test-vault"
_, err := vault.CreateVault(fs, stateDir, vaultName) _, err := vault.CreateVault(fs, stateDir, vaultName)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName) vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600) err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
require.NoError(t, err) require.NoError(t, err)
// Get vault and set up long-term key // Get vault and set up long-term key
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0) ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
require.NoError(t, err) require.NoError(t, err)
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Generate test data of specified size // Generate test data of specified size
testData := make([]byte, tt.size) testData := make([]byte, tt.size)
_, err = rand.Read(testData) _, err = rand.Read(testData)
require.NoError(t, err) require.NoError(t, err)
// Write test data to file // Write test data to file
testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size) testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size)
err = afero.WriteFile(fs, testFile, testData, 0o600) err = afero.WriteFile(fs, testFile, testData, 0o600)
require.NoError(t, err) require.NoError(t, err)
// Create command // Create command
cmd := &cobra.Command{} cmd := &cobra.Command{}
// Create CLI instance // Create CLI instance
cli := NewCLIInstance() cli := NewCLIInstance()
cli.fs = fs cli.fs = fs
cli.stateDir = stateDir cli.stateDir = stateDir
// Test importing the secret // Test importing the secret
secretName := fmt.Sprintf("imported-secret-%d", tt.size) secretName := fmt.Sprintf("imported-secret-%d", tt.size)
err = cli.ImportSecret(cmd, secretName, testFile, false) err = cli.ImportSecret(cmd, secretName, testFile, false)
if tt.shouldError { if tt.shouldError {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg) assert.Contains(t, err.Error(), tt.errorMsg)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
@ -257,22 +257,22 @@ func TestImportSecretVariousSizes(t *testing.T) {
func TestAddSecretBufferGrowth(t *testing.T) { func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth // Test various sizes that should trigger buffer growth
sizes := []int{ sizes := []int{
1, // Single byte 1, // Single byte
100, // Small 100, // Small
4095, // Just under initial 4KB 4095, // Just under initial 4KB
4096, // Exactly 4KB 4096, // Exactly 4KB
4097, // Just over 4KB 4097, // Just over 4KB
8191, // Just under 8KB (first double) 8191, // Just under 8KB (first double)
8192, // Exactly 8KB 8192, // Exactly 8KB
8193, // Just over 8KB 8193, // Just over 8KB
12288, // 12KB (should trigger second double) 12288, // 12KB (should trigger second double)
16384, // 16KB 16384, // 16KB
32768, // 32KB (after more doublings) 32768, // 32KB (after more doublings)
65536, // 64KB 65536, // 64KB
131072, // 128KB 131072, // 128KB
524288, // 512KB 524288, // 512KB
1048576, // 1MB 1048576, // 1MB
2097152, // 2MB 2097152, // 2MB
} }
for _, size := range sizes { for _, size := range sizes {
@ -280,54 +280,54 @@ func TestAddSecretBufferGrowth(t *testing.T) {
// Set up test environment // Set up test environment
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := "/test/state" stateDir := "/test/state"
// Set test mnemonic // Set test mnemonic
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault // Create vault
vaultName := "test-vault" vaultName := "test-vault"
_, err := vault.CreateVault(fs, stateDir, vaultName) _, err := vault.CreateVault(fs, stateDir, vaultName)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName) vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600) err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
require.NoError(t, err) require.NoError(t, err)
// Get vault and set up long-term key // Get vault and set up long-term key
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0) ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
require.NoError(t, err) require.NoError(t, err)
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create test data of exactly the specified size // Create test data of exactly the specified size
// Use a pattern that's easy to verify // Use a pattern that's easy to verify
testData := make([]byte, size) testData := make([]byte, size)
for i := range testData { for i := range testData {
testData[i] = byte(i % 256) testData[i] = byte(i % 256)
} }
// Create fake stdin without newline // Create fake stdin without newline
stdin := bytes.NewReader(testData) stdin := bytes.NewReader(testData)
// Create command with fake stdin // Create command with fake stdin
cmd := &cobra.Command{} cmd := &cobra.Command{}
cmd.SetIn(stdin) cmd.SetIn(stdin)
// Create CLI instance // Create CLI instance
cli := NewCLIInstance() cli := NewCLIInstance()
cli.fs = fs cli.fs = fs
cli.stateDir = stateDir cli.stateDir = stateDir
cli.cmd = cmd cli.cmd = cmd
// Test adding the secret // Test adding the secret
secretName := fmt.Sprintf("buffer-test-%d", size) secretName := fmt.Sprintf("buffer-test-%d", size)
err = cli.AddSecret(secretName, false) err = cli.AddSecret(secretName, false)
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
@ -341,29 +341,29 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
// Set up test environment // Set up test environment
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := "/test/state" stateDir := "/test/state"
// Set test mnemonic // Set test mnemonic
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault // Create vault
vaultName := "test-vault" vaultName := "test-vault"
_, err := vault.CreateVault(fs, stateDir, vaultName) _, err := vault.CreateVault(fs, stateDir, vaultName)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName) vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600) err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
require.NoError(t, err) require.NoError(t, err)
// Get vault and set up long-term key // Get vault and set up long-term key
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0) ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
require.NoError(t, err) require.NoError(t, err)
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create a custom reader that simulates slow streaming input // Create a custom reader that simulates slow streaming input
// This will help verify our buffer handling works correctly with partial reads // This will help verify our buffer handling works correctly with partial reads
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
@ -371,21 +371,21 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
data: testData, data: testData,
chunkSize: 1000, // Read 1KB at a time chunkSize: 1000, // Read 1KB at a time
} }
// Create command with slow reader as stdin // Create command with slow reader as stdin
cmd := &cobra.Command{} cmd := &cobra.Command{}
cmd.SetIn(slowReader) cmd.SetIn(slowReader)
// Create CLI instance // Create CLI instance
cli := NewCLIInstance() cli := NewCLIInstance()
cli.fs = fs cli.fs = fs
cli.stateDir = stateDir cli.stateDir = stateDir
cli.cmd = cmd cli.cmd = cmd
// Test adding the secret // Test adding the secret
err = cli.AddSecret("streaming-test", false) err = cli.AddSecret("streaming-test", false)
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret("streaming-test") retrievedValue, err := vlt.GetSecret("streaming-test")
require.NoError(t, err) require.NoError(t, err)
@ -403,7 +403,7 @@ func (r *slowReader) Read(p []byte) (n int, err error) {
if r.offset >= len(r.data) { if r.offset >= len(r.data) {
return 0, io.EOF return 0, io.EOF
} }
// Read at most chunkSize bytes // Read at most chunkSize bytes
remaining := len(r.data) - r.offset remaining := len(r.data) - r.offset
toRead := r.chunkSize toRead := r.chunkSize
@ -413,13 +413,13 @@ func (r *slowReader) Read(p []byte) (n int, err error) {
if toRead > len(p) { if toRead > len(p) {
toRead = len(p) toRead = len(p)
} }
n = copy(p, r.data[r.offset:r.offset+toRead]) n = copy(p, r.data[r.offset:r.offset+toRead])
r.offset += n r.offset += n
if r.offset >= len(r.data) { if r.offset >= len(r.data) {
err = io.EOF err = io.EOF
} }
return n, err return n, err
} }

View File

@ -1,72 +0,0 @@
package cli_test
import (
"bytes"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret value to stdout, not stderr
func TestGetCommandOutputsToStdout(t *testing.T) {
// Create a temporary directory for our vault
tempDir := t.TempDir()
// Set environment variables for the test
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
// Find the secret binary path
wd, err := filepath.Abs("../..")
require.NoError(t, err, "should get working directory")
secretPath := filepath.Join(wd, "secret")
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testPassphrase := "test-passphrase"
// Initialize vault
cmd := exec.Command(secretPath, "init")
cmd.Env = []string{
"SB_SECRET_STATE_DIR=" + tempDir,
"SB_SECRET_MNEMONIC=" + testMnemonic,
"SB_UNLOCK_PASSPHRASE=" + testPassphrase,
"PATH=" + "/usr/bin:/bin",
}
output, err := cmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", string(output))
// Add a secret
cmd = exec.Command(secretPath, "add", "test/secret")
cmd.Env = []string{
"SB_SECRET_STATE_DIR=" + tempDir,
"SB_SECRET_MNEMONIC=" + testMnemonic,
"PATH=" + "/usr/bin:/bin",
}
cmd.Stdin = strings.NewReader("test-secret-value")
output, err = cmd.CombinedOutput()
require.NoError(t, err, "add should succeed: %s", string(output))
// Test that 'secret get' outputs to stdout, not stderr
cmd = exec.Command(secretPath, "get", "test/secret")
cmd.Env = []string{
"SB_SECRET_STATE_DIR=" + tempDir,
"SB_SECRET_MNEMONIC=" + testMnemonic,
"PATH=" + "/usr/bin:/bin",
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
require.NoError(t, err, "get should succeed")
// The secret value should be in stdout
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()), "secret value should be in stdout")
// Nothing should be in stderr
assert.Empty(t, stderr.String(), "stderr should be empty")
}

View File

@ -4,9 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@ -17,82 +15,28 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// UnlockerInfo represents unlocker information for display // Import from init.go
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"`
}
// Table formatting constants // ... existing imports ...
const (
unlockerIDWidth = 40
unlockerTypeWidth = 12
unlockerDateWidth = 20
unlockerFlagsWidth = 20
)
// getDefaultGPGKey returns the default GPG key ID if available func newUnlockersCmd() *cobra.Command {
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: "unlocker", Use: "unlockers",
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(newUnlockerListCmd()) cmd.AddCommand(newUnlockersListCmd())
cmd.AddCommand(newUnlockerAddCmd()) cmd.AddCommand(newUnlockersAddCmd())
cmd.AddCommand(newUnlockerRemoveCmd()) cmd.AddCommand(newUnlockersRmCmd())
cmd.AddCommand(newUnlockerSelectCmd())
return cmd return cmd
} }
func newUnlockerListCmd() *cobra.Command { func newUnlockersListCmd() *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")
@ -108,115 +52,54 @@ func newUnlockerListCmd() *cobra.Command {
return cmd return cmd
} }
func newUnlockerAddCmd() *cobra.Command { func newUnlockersAddCmd() *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: fmt.Sprintf(`Add a new unlocker to the current vault. Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
Args: cobra.ExactArgs(1),
%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),
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]
// Validate unlocker type return cli.UnlockersAdd(args[0], cmd)
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 (optional, uses default key if not specified)") cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
return cmd return cmd
} }
func newUnlockerRemoveCmd() *cobra.Command { func newUnlockersRmCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
Short: "Remove an unlocker",
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
`will be permanently inaccessible.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
return cli.UnlockersRemove(args[0], force, cmd)
},
}
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
return cmd
}
func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{ return &cobra.Command{
Use: "select <unlocker-id>", Use: "rm <unlocker-id>",
Short: "Select an unlocker as current", Short: "Remove an unlocker",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.UnlockersRemove(args[0])
},
}
}
func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "unlocker",
Short: "Manage current unlocker",
Long: `Select the current unlocker for operations.`,
}
cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd
}
func newUnlockerSelectSubCmd() *cobra.Command {
return &cobra.Command{
Use: "select <unlocker-id>",
Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
@ -233,13 +116,6 @@ 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 {
@ -247,6 +123,13 @@ 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
@ -312,79 +195,54 @@ 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 {
return cli.printUnlockersJSON(unlockers, currentUnlockerID) // JSON output
} output := map[string]interface{}{
"unlockers": unlockers,
return cli.printUnlockersTable(unlockers)
}
// printUnlockersJSON prints unlockers in JSON format
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
output := map[string]interface{}{
"unlockers": unlockers,
"currentUnlockerID": currentUnlockerID,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
cli.cmd.Println(string(jsonBytes))
return nil
}
// printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
return nil
}
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
} }
prefix := " "
if unlocker.IsCurrent {
prefix = "* "
}
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
prefix,
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags)
}
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
cli.cmd.Println(string(jsonBytes))
} else {
// Pretty table output
if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.")
return nil
}
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags)
}
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
@ -416,20 +274,9 @@ 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)
@ -440,52 +287,17 @@ 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, environment, or default key // Get GPG key ID from flag or environment variable
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 {
// Try to get the default GPG key return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
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)
@ -496,63 +308,22 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID()) cmd.Printf("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: %s)", unlockerType, supportedTypes) return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
} }
} }
// UnlockersRemove removes an unlocker with safety checks // UnlockersRemove removes an unlocker
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error { func (cli *Instance) UnlockersRemove(unlockerID 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 {
return err return err
} }
// Get list of unlockers return vlt.RemoveUnlocker(unlockerID)
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
@ -565,69 +336,3 @@ 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,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -28,16 +27,14 @@ 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
} }
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")
@ -66,13 +63,10 @@ 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()
@ -82,14 +76,11 @@ 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 {
@ -103,29 +94,6 @@ func newVaultImportCmd() *cobra.Command {
} }
} }
func newVaultRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
Short: "Remove a vault",
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
`switch to another vault if removing the currently selected one.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
return cli.RemoveVault(cmd, args[0], force)
},
}
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
return cmd
}
// ListVaults lists all available vaults // 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)
@ -179,95 +147,12 @@ 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
} }
@ -410,90 +295,3 @@ 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

@ -33,11 +33,9 @@ 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])
}, },
@ -49,40 +47,12 @@ 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])
}, },
} }
// Remove version command versionCmd.AddCommand(listCmd, promoteCmd)
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
} }
@ -237,60 +207,3 @@ 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) (*memguard.LockedBuffer, error) { func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, 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) (*memguard.LockedBu
return nil, fmt.Errorf("failed to read decrypted data: %w", err) return nil, fmt.Errorf("failed to read decrypted data: %w", err)
} }
// Create a secure buffer for the decrypted data return result, nil
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
// Both data and passphrase parameters should be LockedBuffers for secure memory handling // The passphrase parameter should be a LockedBuffer for secure memory handling
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) { func EncryptWithPassphrase(data []byte, 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")
} }
// Create recipient directly from passphrase - unavoidable string conversion due to age API // Get the passphrase string temporarily
recipient, err := age.NewScryptRecipient(passphrase.String()) passphraseStr := 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)
} }
return EncryptToRecipient(data, recipient) // Create a secure buffer for the data
dataBuffer := memguard.NewBufferFromBytes(data)
defer dataBuffer.Destroy()
return EncryptToRecipient(dataBuffer, recipient)
} }
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption // 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) (*memguard.LockedBuffer, error) { func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
if passphrase == nil { if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil") return nil, fmt.Errorf("passphrase buffer is nil")
} }
// Create identity directly from passphrase - unavoidable string conversion due to age API // Get the passphrase string temporarily
identity, err := age.NewScryptIdentity(passphrase.String()) passphraseStr := 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,6 +1,3 @@
//go:build darwin
// +build darwin
package secret package secret
import ( import (
@ -9,22 +6,19 @@ 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
@ -113,22 +107,30 @@ 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()
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer) agePrivKeyData, 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", agePrivKeyBuffer.Size()), slog.Int("decrypted_length", len(agePrivKeyData)),
) )
// 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())
@ -161,18 +163,15 @@ 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 in the format YYYY-MM-DD.HH.mm-hostname-keychain // Generate ID using keychain item name
// This matches the passphrase unlocker format keychainItemName, err := k.GetKeychainItemName()
hostname, err := os.Hostname()
if err != nil { if err != nil {
hostname = "unknown" // The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
} }
// Use the creation timestamp from metadata return fmt.Sprintf("%s-keychain", keychainItemName)
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
@ -302,13 +301,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
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity) ltPrivKeyData, 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 buffer // Return the decrypted key in a secure buffer
return ltPrivKeyBuffer, nil return memguard.NewBufferFromBytes(ltPrivKeyData), 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
@ -369,7 +368,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, passphraseBuffer) encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), 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)
} }
@ -410,12 +409,8 @@ 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, keychainDataBuffer); err != nil { if err := storeInKeychain(keychainItemName, keychainDataBytes); 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)
} }
@ -447,10 +442,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}, nil }, nil
} }
// checkMacOSAvailable verifies that we're running on macOS // checkMacOSAvailable verifies that we're running on macOS and security command is available
func checkMacOSAvailable() error { func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" { cmd := exec.Command("/usr/bin/security", "help")
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS) if err := cmd.Run(); err != nil {
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
} }
return nil return nil
@ -469,91 +465,59 @@ func validateKeychainItemName(itemName string) error {
return nil return nil
} }
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain // storeInKeychain stores data in the macOS keychain using the security command
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error { func storeInKeychain(itemName string, data []byte) 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
item := keychain.NewItem() if err := cmd.Run(); err != nil {
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 keybase/go-keychain // retrieveFromKeychain retrieves data from the macOS keychain using the security command
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)
} }
query := keychain.NewItem() cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
query.SetSecClass(keychain.SecClassGenericPassword) "-a", itemName,
query.SetService(KEYCHAIN_APP_IDENTIFIER) "-s", itemName,
query.SetAccount(itemName) "-w") // Return password only
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query) output, err := cmd.Output()
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)
} }
if len(results) == 0 { // Remove trailing newline if present
return nil, fmt.Errorf("keychain item not found: %s", itemName) if len(output) > 0 && output[len(output)-1] == '\n' {
output = output[:len(output)-1]
} }
return results[0].Data, nil return output, nil
} }
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain // deleteFromKeychain removes an item from the macOS keychain using the security command
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
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)
} }
item := keychain.NewItem() cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
item.SetSecClass(keychain.SecClassGenericPassword) "-a", itemName,
item.SetService(KEYCHAIN_APP_IDENTIFIER) "-s", itemName)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
if err := cmd.Run(); 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

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

View File

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

View File

@ -76,11 +76,10 @@ 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) {
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey)) privKeyData := []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(privKeyBuffer, passphraseBuffer) encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, 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,22 +84,30 @@ 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
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer) privKeyData, 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", privKeyBuffer.Size()), slog.Int("decrypted_length", len(privKeyData)),
) )
// 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,10 +45,7 @@ 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 *memguard.LockedBuffer, keyID string) ([]byte, error) { secret.GPGEncryptFunc = func(data []byte, 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",
@ -63,7 +60,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.Bytes()) cmd.Stdin = bytes.NewReader(data)
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())
@ -72,7 +69,7 @@ pinentry-mode loopback
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) { secret.GPGDecryptFunc = func(encryptedData []byte) ([]byte, error) {
cmd := exec.Command("gpg", cmd := exec.Command("gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
@ -91,8 +88,7 @@ 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())
} }
// Create a secure buffer for the decrypted data return stdout.Bytes(), nil
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
} }
// Restore original functions after test // Restore original functions after test
@ -448,9 +444,8 @@ Passphrase: ` + testPassphrase + `
} }
// GPG encrypt the private key using our custom encrypt function // GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String())) privKeyData := []byte(ageIdentity.String())
defer privKeyBuffer.Destroy() encryptedOutput, err := secret.GPGEncryptFunc(privKeyData, keyID)
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,13 +20,11 @@ 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
//nolint:gochecknoglobals // Required for test mocking GPGEncryptFunc = gpgEncryptDefault //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
//nolint:gochecknoglobals // Required for test mocking GPGDecryptFunc = gpgDecryptDefault //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:
@ -81,22 +79,21 @@ 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())
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData) agePrivKeyData, 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", agePrivKeyBuffer.Size()), slog.Int("decrypted_length", len(agePrivKeyData)),
) )
// 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(agePrivKeyBuffer.String()) ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
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())
@ -128,7 +125,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: pgp-<keyid> // Generate ID using GPG key ID: <keyid>-pgp
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
@ -136,7 +133,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("pgp-%s", gpgKeyID) return fmt.Sprintf("%s-pgp", gpgKeyID)
} }
// Remove implements Unlocker interface - removes the PGP unlocker // Remove implements Unlocker interface - removes the PGP unlocker
@ -256,7 +253,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String())) agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer agePrivateKeyBuffer.Destroy() defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID) encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), 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)
} }
@ -267,7 +264,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
} }
// Step 9: Resolve the GPG key ID to its full fingerprint // 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)
} }
@ -313,8 +310,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)
} }
@ -351,16 +348,13 @@ func checkGPGAvailable() error {
} }
// gpgEncryptDefault is the default implementation of GPG encryption // gpgEncryptDefault is the default implementation of GPG encryption
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) { func gpgEncryptDefault(data []byte, 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(data.String()) cmd.Stdin = strings.NewReader(string(data))
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@ -371,7 +365,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
} }
// gpgDecryptDefault is the default implementation of GPG decryption // gpgDecryptDefault is the default implementation of GPG decryption
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) { func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
cmd := exec.Command("gpg", "--quiet", "--decrypt") cmd := exec.Command("gpg", "--quiet", "--decrypt")
cmd.Stdin = strings.NewReader(string(encryptedData)) cmd.Stdin = strings.NewReader(string(encryptedData))
@ -380,8 +374,5 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
return nil, fmt.Errorf("GPG decryption failed: %w", err) return nil, fmt.Errorf("GPG decryption failed: %w", err)
} }
// Create a secure buffer for the decrypted data return output, nil
outputBuffer := memguard.NewBufferFromBytes(output)
return outputBuffer, nil
} }

View File

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

@ -101,9 +101,10 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
return err return err
} }
// Create current file pointing to the version (just the version name) // Create current symlink pointing to the version
currentLink := filepath.Join(secretDir, "current") currentLink := filepath.Join(secretDir, "current")
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil { // For MemMapFs, write a file with the target path
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0o600); err != nil {
return err return err
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -276,16 +277,15 @@ 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
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity) versionPrivKeyData, 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(versionPrivKeyBuffer.String()) versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
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,17 +302,16 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
} }
// Step 5: Decrypt metadata using version key // Step 5: Decrypt metadata using version key
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity) metadataBytes, 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(metadataBuffer.Bytes(), &metadata); err != nil { if err := json.Unmarshal(metadataBytes, &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)
@ -325,7 +324,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) (*memguard.LockedBuffer, error) { func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, 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),
@ -353,17 +352,16 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// 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)
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity) versionPrivKeyData, 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)
} }
defer versionPrivKeyBuffer.Destroy() Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
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(versionPrivKeyBuffer.String()) versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
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)
@ -383,7 +381,7 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// 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)
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity) value, 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)
@ -392,10 +390,10 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
Debug("Successfully retrieved version value", Debug("Successfully retrieved version value",
"version", sv.Version, "version", sv.Version,
"value_length", valueBuffer.Size(), "value_length", len(value),
"is_empty", valueBuffer.Size() == 0) "is_empty", len(value) == 0)
return valueBuffer, nil return value, nil
} }
// ListVersions lists all versions of a secret // ListVersions lists all versions of a secret
@ -430,32 +428,59 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
return versions, nil return versions, nil
} }
// GetCurrentVersion returns the version that the "current" file points to // GetCurrentVersion returns the version that the "current" symlink points to
// The file contains just the version name (e.g., "20231215.001")
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) { func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
fileData, err := afero.ReadFile(fs, currentPath) // Try to read as a real symlink first
if err != nil { if _, ok := fs.(*afero.OsFs); ok {
return "", fmt.Errorf("failed to read current version file: %w", err) target, err := os.Readlink(currentPath)
if err == nil {
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
}
} }
version := strings.TrimSpace(string(fileData)) // Fall back to reading as a file (for MemMapFs testing)
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
return "", fmt.Errorf("failed to read current version symlink: %w", err)
}
return version, nil target := strings.TrimSpace(string(fileData))
// Extract version from path
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
} }
// SetCurrentVersion updates the "current" file to point to a specific version // SetCurrentVersion updates the "current" symlink to point to a specific version
// The file contains just the version name (e.g., "20231215.001")
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error { func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
targetPath := filepath.Join("versions", version)
// Remove existing file if it exists // Remove existing symlink if it exists
_ = fs.Remove(currentPath) _ = fs.Remove(currentPath)
// Write just the version name to the file // Try to create a real symlink first (works on Unix systems)
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil { if _, ok := fs.(*afero.OsFs); ok {
return fmt.Errorf("failed to create current version file: %w", err) if err := os.Symlink(targetPath, currentPath); err == nil {
return nil
}
}
// Fall back to creating a file with the target path (for MemMapFs testing)
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
return fmt.Errorf("failed to create current version symlink: %w", err)
} }
return nil return nil

View File

@ -255,11 +255,10 @@ func TestSecretVersionGetValue(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Retrieve the value // Retrieve the value
retrievedBuffer, err := sv.GetValue(ltIdentity) retrievedValue, err := sv.GetValue(ltIdentity)
require.NoError(t, err) require.NoError(t, err)
defer retrievedBuffer.Destroy()
assert.Equal(t, expectedValue, retrievedBuffer.Bytes()) assert.Equal(t, expectedValue, retrievedValue)
} }
func TestListVersions(t *testing.T) { func TestListVersions(t *testing.T) {
@ -296,12 +295,12 @@ func TestGetCurrentVersion(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
secretDir := "/test/secret" secretDir := "/test/secret"
// The current file contains just the version name // Simulate symlink with file content (works for both OsFs and MemMapFs)
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
err := fs.MkdirAll(secretDir, 0o755) err := fs.MkdirAll(secretDir, 0o755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600) err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0o600)
require.NoError(t, err) require.NoError(t, err)
version, err := GetCurrentVersion(fs, secretDir) version, err := GetCurrentVersion(fs, secretDir)

View File

@ -26,9 +26,9 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase") t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
// Test currentvault file handling (plain file with relative path) // Test symlink handling
t.Run("CurrentVaultFileHandling", func(t *testing.T) { t.Run("SymlinkHandling", func(t *testing.T) {
stateDir := filepath.Join(tempDir, "currentvault-test") stateDir := filepath.Join(tempDir, "symlink-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil { if err := os.MkdirAll(stateDir, 0o700); err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
@ -45,26 +45,31 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to get vault directory: %v", err) t.Fatalf("Failed to get vault directory: %v", err)
} }
// Verify the currentvault file exists and contains just the vault name // Create a symlink to the vault directory in a different location
currentVaultPath := filepath.Join(stateDir, "currentvault") symlinkPath := filepath.Join(tempDir, "test-symlink")
currentVaultContents, err := os.ReadFile(currentVaultPath) if err := os.Symlink(vaultDir, symlinkPath); err != nil {
t.Fatalf("Failed to create symlink: %v", err)
}
// Test that we can resolve the symlink correctly
resolvedPath, err := vault.ResolveVaultSymlink(fs, symlinkPath)
if err != nil { if err != nil {
t.Fatalf("Failed to read currentvault file: %v", err) t.Fatalf("Failed to resolve symlink: %v", err)
} }
expectedVaultName := "test-vault" // On some platforms, the resolved path might have different case or format
if string(currentVaultContents) != expectedVaultName { // We'll use filepath.EvalSymlinks to get the canonical path for comparison
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents)) expectedPath, err := filepath.EvalSymlinks(vaultDir)
}
// Test that ResolveVaultSymlink correctly resolves the path
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
if err != nil { if err != nil {
t.Fatalf("Failed to resolve currentvault path: %v", err) t.Fatalf("Failed to evaluate symlink: %v", err)
}
actualPath, err := filepath.EvalSymlinks(resolvedPath)
if err != nil {
t.Fatalf("Failed to evaluate resolved path: %v", err)
} }
if resolvedPath != vaultDir { if actualPath != expectedPath {
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath) t.Errorf("Expected symlink to resolve to %s, got %s", expectedPath, actualPath)
} }
}) })

View File

@ -6,7 +6,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
@ -32,31 +31,104 @@ func isValidVaultName(name string) bool {
return matched return matched
} }
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault // resolveRelativeSymlink resolves a relative symlink target to an absolute path
// The file contains just the vault name (e.g., "default") func resolveRelativeSymlink(symlinkPath, _ string) (string, error) {
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) { // Get the current directory before changing
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath) originalDir, err := os.Getwd()
fileData, err := afero.ReadFile(fs, currentVaultPath)
if err != nil { if err != nil {
secret.Debug("Failed to read currentvault file", "error", err) return "", fmt.Errorf("failed to get current directory: %w", err)
return "", fmt.Errorf("failed to read currentvault file: %w", err)
} }
secret.Debug("Got current directory", "original_dir", originalDir)
// The file contains just the vault name like "default" // Change to the symlink's directory
vaultName := strings.TrimSpace(string(fileData)) symlinkDir := filepath.Dir(symlinkPath)
secret.Debug("Read vault name from file", "vault_name", vaultName) secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
if err := os.Chdir(symlinkDir); err != nil {
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
}
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
// Resolve to absolute path: stateDir/vaults.d/vaultName // Get the absolute path of the target
stateDir := filepath.Dir(currentVaultPath) secret.Debug("Getting absolute path of current directory")
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName) absolutePath, err := os.Getwd()
if err != nil {
// Try to restore original directory before returning error
_ = os.Chdir(originalDir)
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath) return "", fmt.Errorf("failed to get absolute path: %w", err)
}
secret.Debug("Got absolute path", "absolute_path", absolutePath)
// Restore the original directory
secret.Debug("Restoring original directory", "original_dir", originalDir)
if err := os.Chdir(originalDir); err != nil {
return "", fmt.Errorf("failed to restore original directory: %w", err)
}
secret.Debug("Restored original directory successfully")
return absolutePath, nil return absolutePath, nil
} }
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
// First try to handle the path as a real symlink (works on Unix systems)
_, isOsFs := fs.(*afero.OsFs)
if isOsFs {
target, err := tryResolveOsSymlink(symlinkPath)
if err == nil {
secret.Debug("resolveVaultSymlink completed successfully", "result", target)
return target, nil
}
// Fall through to fallback if symlink resolution failed
} else {
secret.Debug("Not using OS filesystem, skipping symlink resolution")
}
// Fallback: treat it as a regular file containing the target path
secret.Debug("Fallback: trying to read regular file with target path")
fileData, err := afero.ReadFile(fs, symlinkPath)
if err != nil {
secret.Debug("Failed to read target path file", "error", err)
return "", fmt.Errorf("failed to read vault symlink: %w", err)
}
target := string(fileData)
secret.Debug("Read target path from file", "target", target)
secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
return target, nil
}
// tryResolveOsSymlink attempts to resolve a symlink on OS filesystems
func tryResolveOsSymlink(symlinkPath string) (string, error) {
secret.Debug("Using real filesystem symlink resolution")
// Check if the symlink exists
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
target, err := os.Readlink(symlinkPath)
if err != nil {
return "", err
}
secret.Debug("Symlink points to", "target", target)
// On real filesystem, we need to handle relative symlinks
// by resolving them relative to the symlink's directory
if !filepath.IsAbs(target) {
return resolveRelativeSymlink(symlinkPath, target)
}
return target, nil
}
// GetCurrentVault gets the current vault from the file system // GetCurrentVault gets the current vault from the file system
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) { func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
secret.Debug("Getting current vault", "state_dir", stateDir) secret.Debug("Getting current vault", "state_dir", stateDir)
@ -256,18 +328,32 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
return fmt.Errorf("vault %s does not exist", name) return fmt.Errorf("vault %s does not exist", name)
} }
// Create or update the currentvault file with just the vault name // Create or update the current vault symlink/file
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
targetPath := filepath.Join(stateDir, "vaults.d", name)
// Remove existing file if it exists // First try to remove existing symlink if it exists
if _, err := fs.Stat(currentVaultPath); err == nil { if _, err := fs.Stat(currentVaultPath); err == nil {
secret.Debug("Removing existing currentvault file", "path", currentVaultPath) secret.Debug("Removing existing current vault symlink", "path", currentVaultPath)
// Ignore errors from Remove as we'll try to create/update it anyway.
// On some systems, removing a symlink may fail but the subsequent create may still succeed.
_ = fs.Remove(currentVaultPath) _ = fs.Remove(currentVaultPath)
} }
// Write just the vault name to the file // Try to create a real symlink first (works on Unix systems)
secret.Debug("Writing currentvault file", "vault_name", name) if _, ok := fs.(*afero.OsFs); ok {
if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil { secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
if err := os.Symlink(targetPath, currentVaultPath); err == nil {
secret.Debug("Successfully selected vault", "vault_name", name)
return nil
}
// If symlink creation fails, fall back to regular file
}
// Fallback: create a regular file with the target path
secret.Debug("Fallback: creating regular file with target path", "target", targetPath)
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), secret.FilePerms); err != nil {
return fmt.Errorf("failed to select vault: %w", err) return fmt.Errorf("failed to select vault: %w", err)
} }

View File

@ -204,12 +204,6 @@ 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)
} }
@ -265,14 +259,13 @@ 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
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity) versionPrivKeyData, 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(versionPrivKeyBuffer.String()) versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
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)
} }
@ -400,26 +393,21 @@ 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(result)), slog.Int("decrypted_length", len(decryptedValue)),
) )
// 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(result), "decrypted_value_length", len(decryptedValue),
"is_empty", len(result) == 0) "is_empty", len(decryptedValue) == 0)
return result, nil return decryptedValue, nil
} }
// UnlockVault unlocks the vault and returns the long-term private key // UnlockVault unlocks the vault and returns the long-term private key
@ -483,158 +471,3 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
return secretObj, nil return secretObj, nil
} }
// CopySecretVersion copies a single version from source to this vault
// It decrypts the value using srcIdentity and re-encrypts for this vault
func (v *Vault) CopySecretVersion(
srcVersion *secret.Version,
srcIdentity *age.X25519Identity,
destSecretName string,
destVersionName string,
) error {
secret.DebugWith("Copying secret version to vault",
slog.String("src_secret", srcVersion.SecretName),
slog.String("src_version", srcVersion.Version),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.String("dest_version", destVersionName),
)
// Get the decrypted value from source
valueBuffer, err := srcVersion.GetValue(srcIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt source version: %w", err)
}
defer valueBuffer.Destroy()
// Load source metadata
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
return fmt.Errorf("failed to load source metadata: %w", err)
}
// Create destination version with same name
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
// Copy metadata (preserve original timestamps)
destVersion.Metadata = srcVersion.Metadata
// Save the version (encrypts to this vault's LT key)
if err := destVersion.Save(valueBuffer); err != nil {
return fmt.Errorf("failed to save destination version: %w", err)
}
secret.Debug("Successfully copied secret version",
"src_version", srcVersion.Version,
"dest_version", destVersionName,
"dest_vault", v.Name)
return nil
}
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
// It re-encrypts each version with this vault's long-term key
func (v *Vault) CopySecretAllVersions(
srcVault *Vault,
srcSecretName string,
destSecretName string,
force bool,
) error {
secret.DebugWith("Copying all secret versions between vaults",
slog.String("src_vault", srcVault.Name),
slog.String("src_secret", srcSecretName),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.Bool("force", force),
)
// Get destination vault directory
destVaultDir, err := v.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Check if destination secret already exists
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
exists, err := afero.DirExists(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to check destination: %w", err)
}
if exists && !force {
return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)",
destSecretName, v.Name)
}
if exists && force {
// Remove existing secret
secret.Debug("Removing existing destination secret", "path", destSecretDir)
if err := v.fs.RemoveAll(destSecretDir); err != nil {
return fmt.Errorf("failed to remove existing destination secret: %w", err)
}
}
// Get source vault's long-term key
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
}
// Get source secret directory
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
// List all versions
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to list source versions: %w", err)
}
if len(versions) == 0 {
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
}
// Get current version name
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to get current version: %w", err)
}
// Create destination secret directory
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
return fmt.Errorf("failed to create destination secret directory: %w", err)
}
// Copy each version
for _, versionName := range versions {
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil {
// Rollback: remove partial copy
secret.Debug("Rolling back partial copy due to error", "error", err)
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
}
}
// Set current version
if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil {
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.DebugWith("Successfully copied all secret versions",
slog.String("src_vault", srcVault.Name),
slog.String("dest_vault", v.Name),
slog.Int("version_count", len(versions)),
)
return nil
}

View File

@ -98,28 +98,38 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
return unlocker, nil return unlocker, nil
} }
// resolveUnlockerDirectory reads the current-unlocker file to get the unlocker directory path // resolveUnlockerDirectory resolves the unlocker directory from a symlink or file
// The file contains just the unlocker name (e.g., "passphrase")
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) linkReader, ok := v.fs.(afero.LinkReader)
if !ok {
// Fallback for filesystems that don't support symlinks
return v.readUnlockerPathFromFile(currentUnlockerPath)
}
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath) secret.Debug("Resolving unlocker symlink using afero")
// Try to read as symlink first
unlockerDir, err := linkReader.ReadlinkIfPossible(currentUnlockerPath)
if err == nil {
return unlockerDir, nil
}
secret.Debug("Failed to read symlink, falling back to file contents",
"error", err, "symlink_path", currentUnlockerPath)
// Fallback: read the path from file contents
return v.readUnlockerPathFromFile(currentUnlockerPath)
}
// readUnlockerPathFromFile reads the unlocker directory path from a file
func (v *Vault) readUnlockerPathFromFile(path string) (string, error) {
secret.Debug("Reading unlocker path from file", "path", path)
unlockerDirBytes, err := afero.ReadFile(v.fs, path)
if err != nil { if err != nil {
secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath) secret.Debug("Failed to read unlocker path file", "error", err, "path", path)
return "", fmt.Errorf("failed to read current unlocker: %w", err) return "", fmt.Errorf("failed to read current unlocker: %w", err)
} }
unlockerName := strings.TrimSpace(string(unlockerNameBytes)) return strings.TrimSpace(string(unlockerDirBytes)), nil
secret.Debug("Read unlocker name from file", "unlocker_name", unlockerName)
// Resolve to absolute path: vaultDir/unlockers.d/unlockerName
vaultDir := filepath.Dir(currentUnlockerPath)
absolutePath := filepath.Join(vaultDir, "unlockers.d", unlockerName)
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
return absolutePath, nil
} }
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path // findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
@ -277,25 +287,30 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return fmt.Errorf("unlocker with ID %s not found", unlockerID) return fmt.Errorf("unlocker with ID %s not found", unlockerID)
} }
// Create/update current-unlocker file with just the unlocker name // Create/update current unlocker symlink
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Remove existing file if it exists // Remove existing symlink if it exists
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil { if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err) return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err)
} else if exists { } else if exists {
if err := v.fs.Remove(currentUnlockerPath); err != nil { if err := v.fs.Remove(currentUnlockerPath); err != nil {
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err) return fmt.Errorf("failed to remove existing unlocker symlink: %w", err)
} }
} }
// Get just the unlocker name (basename of the directory) // Create new symlink using afero's SymlinkIfPossible
unlockerName := filepath.Base(targetUnlockerDir) if linker, ok := v.fs.(afero.Linker); ok {
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
// Write just the unlocker name to the file if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName) return fmt.Errorf("failed to create unlocker symlink: %w", err)
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil { }
return fmt.Errorf("failed to create current-unlocker file: %w", err) } else {
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
}
} }
return nil return nil
@ -331,9 +346,7 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
// Encrypt private key with passphrase // Encrypt private key with passphrase
privKeyStr := unlockerIdentity.String() privKeyStr := unlockerIdentity.String()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr)) encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase)
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,23 +157,22 @@ 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())
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity) ltPrivKeyData, 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", ltPrivKeyBuffer.Size()), slog.Int("decrypted_length", len(ltPrivKeyData)),
) )
// 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(ltPrivKeyBuffer.String()) ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
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)
@ -208,48 +207,3 @@ 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

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