Compare commits
25 Commits
fix-memory
...
main
Author | SHA1 | Date | |
---|---|---|---|
92c41bdb0c | |||
75c3d22b62 | |||
a6f24e9581 | |||
a73a409fe4 | |||
70d19d09d0 | |||
40ea47b2a1 | |||
7ed3e287ea | |||
8e3530a510 | |||
e5d7407c79 | |||
377b51f2db | |||
a09fa89f30 | |||
7af1e6efa8 | |||
09b3a1fcdc | |||
816f53f819 | |||
bba1fb21e6 | |||
d4f557631b | |||
e53161188c | |||
ff17b9b107 | |||
63cc06b93c | |||
8ec3fc877d | |||
819902f385 | |||
292564c6e7 | |||
eef2332823 | |||
e82d428b05 | |||
9cbe055791 |
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(go mod why:*)",
|
|
||||||
"Bash(go list:*)",
|
|
||||||
"Bash(~/go/bin/govulncheck -mode=module .)",
|
|
||||||
"Bash(go test:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(rg:*)",
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(make test:*)",
|
|
||||||
"Bash(go doc:*)",
|
|
||||||
"Bash(make fmt:*)",
|
|
||||||
"Bash(make:*)",
|
|
||||||
"Bash(golangci-lint run:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(gofumpt:*)",
|
|
||||||
"Bash(git stash:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)",
|
|
||||||
"Bash(golangci-lint:*)",
|
|
||||||
"Bash(git checkout:*)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"WebFetch(domain:golangci-lint.run)",
|
|
||||||
"Bash(go:*)",
|
|
||||||
"WebFetch(domain:pkg.go.dev)"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
}
|
|
||||||
}
|
|
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Build artifacts
|
||||||
|
secret
|
||||||
|
coverage.out
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude files
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
.golangci.yml
|
||||||
|
.claude/settings.local.json
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@
|
|||||||
cli.test
|
cli.test
|
||||||
vault.test
|
vault.test
|
||||||
*.test
|
*.test
|
||||||
|
settings.local.json
|
||||||
|
@ -64,6 +64,14 @@ linters-settings:
|
|||||||
nlreturn:
|
nlreturn:
|
||||||
block-size: 2
|
block-size: 2
|
||||||
|
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
arguments:
|
||||||
|
- []
|
||||||
|
- []
|
||||||
|
- "upperCaseConst=true"
|
||||||
|
|
||||||
tagliatelle:
|
tagliatelle:
|
||||||
case:
|
case:
|
||||||
rules:
|
rules:
|
||||||
@ -89,3 +97,32 @@ issues:
|
|||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
- text: "parameter '(args|cmd)' seems to be unused"
|
||||||
linters:
|
linters:
|
||||||
- revive
|
- revive
|
||||||
|
|
||||||
|
# Allow ALL_CAPS constant names
|
||||||
|
- text: "don't use ALL_CAPS in Go names"
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Exclude all linters for internal/macse directory
|
||||||
|
- path: "internal/macse/.*"
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- lll
|
||||||
|
- mnd
|
||||||
|
- nestif
|
||||||
|
- nlreturn
|
||||||
|
- revive
|
||||||
|
- unconvert
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- gosec
|
||||||
|
- unparam
|
||||||
|
- testifylint
|
||||||
|
- usetesting
|
||||||
|
- tagliatelle
|
||||||
|
- nilnil
|
||||||
|
- intrange
|
||||||
|
- gochecknoglobals
|
||||||
|
@ -26,3 +26,5 @@ Read the rules in AGENTS.md and follow them.
|
|||||||
* Do not stop working on a task until you have reached the definition of
|
* Do not stop working on a task until you have reached the definition of
|
||||||
done provided to you in the initial instruction. Don't do part or most of
|
done provided to you in the initial instruction. Don't do part or most of
|
||||||
the work, do all of the work until the criteria for done are met.
|
the work, do all of the work until the criteria for done are met.
|
||||||
|
|
||||||
|
* When you complete each task, if the tests are passing and the code is formatted and there are no linter errors, always commit and push your work. Use a good commit message and don't mention any author or co-author attribution.
|
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
make \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
gnupg
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN adduser -D -s /bin/sh secret
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/secret /usr/local/bin/secret
|
||||||
|
|
||||||
|
# Ensure binary is executable
|
||||||
|
RUN chmod +x /usr/local/bin/secret
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER secret
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /home/secret
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["secret"]
|
30
Makefile
30
Makefile
@ -1,15 +1,23 @@
|
|||||||
|
export CGO_ENABLED=1
|
||||||
|
export DOCKER_HOST := ssh://root@ber1app1.local
|
||||||
|
|
||||||
|
# Version information
|
||||||
|
VERSION := 0.1.0
|
||||||
|
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||||
|
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
|
||||||
|
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
|
||||||
|
|
||||||
default: check
|
default: check
|
||||||
|
|
||||||
build: ./secret
|
build: ./secret
|
||||||
|
|
||||||
# Simple build (no code signing needed)
|
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
|
||||||
./secret:
|
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
|
||||||
go build -v -o $@ cmd/secret/main.go
|
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
test:
|
test: lint vet
|
||||||
go test ./... || go test -v ./...
|
go test ./... || go test -v ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@ -18,9 +26,19 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run --timeout 5m
|
golangci-lint run --timeout 5m
|
||||||
|
|
||||||
# Check all code quality (build + vet + lint + unit tests)
|
check: build test
|
||||||
check: ./secret vet lint test
|
|
||||||
|
# Build Docker container
|
||||||
|
docker:
|
||||||
|
docker build -t sneak/secret .
|
||||||
|
|
||||||
|
# Run Docker container interactively
|
||||||
|
docker-run:
|
||||||
|
docker run --rm -it sneak/secret
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -f ./secret
|
rm -f ./secret
|
||||||
|
|
||||||
|
install: ./secret
|
||||||
|
cp ./secret $(HOME)/bin/secret
|
||||||
|
112
README.md
112
README.md
@ -1,12 +1,12 @@
|
|||||||
# Secret - Hierarchical Secret Manager
|
# Secret - Hierarchical Secret Manager
|
||||||
|
|
||||||
Secret is a modern, secure command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
|
Secret is a command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
### Three-Layer Key Hierarchy
|
### Three-Layer Key Hierarchy
|
||||||
|
|
||||||
Secret implements a sophisticated three-layer key architecture:
|
Secret implements a three-layer key architecture:
|
||||||
|
|
||||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
|
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
|
||||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
|
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
|
||||||
@ -16,7 +16,7 @@ Secret implements a sophisticated three-layer key architecture:
|
|||||||
|
|
||||||
Each secret maintains a history of versions, with each version having:
|
Each secret maintains a history of versions, with each version having:
|
||||||
- Its own encryption key pair
|
- Its own encryption key pair
|
||||||
- Encrypted metadata including creation time and validity period
|
- Metadata (unencrypted) including creation time and validity period
|
||||||
- Immutable value storage
|
- Immutable value storage
|
||||||
- Atomic version switching via symlink updates
|
- Atomic version switching via symlink updates
|
||||||
|
|
||||||
@ -69,8 +69,8 @@ Initializes the secret manager with a default vault. Prompts for a BIP39 mnemoni
|
|||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
#### `secret vault list [--json]`
|
#### `secret vault list [--json]` / `secret vault ls`
|
||||||
Lists all available vaults.
|
Lists all available vaults. The current vault is marked.
|
||||||
|
|
||||||
#### `secret vault create <name>`
|
#### `secret vault create <name>`
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
@ -78,6 +78,12 @@ Creates a new vault with the specified name.
|
|||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|
||||||
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
Requires --force if the vault contains secrets. With --force, will automatically switch to another vault if removing the current one.
|
||||||
|
- `--force, -f`: Force removal even if vault contains secrets
|
||||||
|
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
|
|
||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
@ -95,14 +101,29 @@ Retrieves and outputs a secret value to stdout.
|
|||||||
#### `secret list [filter] [--json]` / `secret ls`
|
#### `secret list [filter] [--json]` / `secret ls`
|
||||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||||
|
|
||||||
|
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
||||||
|
|
||||||
|
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||||
|
Moves or renames a secret within the current vault.
|
||||||
|
- Fails if the destination already exists
|
||||||
|
- Preserves all versions and metadata
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
#### `secret version list <secret-name>`
|
#### `secret version list <secret-name>` / `secret version ls`
|
||||||
Lists all versions of a secret showing creation time, status, and validity period.
|
Lists all versions of a secret showing creation time, status, and validity period.
|
||||||
|
|
||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
Promotes a specific version to current by updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
|
Promotes a specific version to current by updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
|
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a specific version of a secret. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||||
|
- Cannot remove the current version (must promote another version first)
|
||||||
|
|
||||||
### Key Generation
|
### Key Generation
|
||||||
|
|
||||||
#### `secret generate mnemonic`
|
#### `secret generate mnemonic`
|
||||||
@ -116,21 +137,26 @@ Generates and stores a random secret.
|
|||||||
|
|
||||||
### Unlocker Management
|
### Unlocker Management
|
||||||
|
|
||||||
#### `secret unlockers list [--json]`
|
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
||||||
Lists all unlockers in the current vault with their metadata.
|
Lists all unlockers in the current vault with their metadata.
|
||||||
|
|
||||||
#### `secret unlockers add <type> [options]`
|
#### `secret unlocker add <type> [options]`
|
||||||
Creates a new unlocker of the specified type:
|
Creates a new unlocker of the specified type:
|
||||||
|
|
||||||
**Types:**
|
**Types:**
|
||||||
- `passphrase`: Traditional passphrase-protected unlocker
|
- `passphrase`: Traditional passphrase-protected unlocker
|
||||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||||
|
- `keychain`: macOS Keychain integration (macOS only)
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (required for PGP type)
|
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
||||||
|
|
||||||
#### `secret unlockers rm <unlocker-id>`
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
Removes an unlocker.
|
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
Cannot remove the last unlocker if the vault has secrets unless --force is used.
|
||||||
|
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
||||||
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, vault data will be PERMANENTLY INACCESSIBLE
|
||||||
|
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means losing access to all secrets forever
|
||||||
|
|
||||||
#### `secret unlocker select <unlocker-id>`
|
#### `secret unlocker select <unlocker-id>`
|
||||||
Selects an unlocker as the current default for operations.
|
Selects an unlocker as the current default for operations.
|
||||||
@ -169,7 +195,7 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
||||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||||
│ │ │ │ └── current -> versions/20231216.001
|
│ │ │ │ └── current -> versions/20231216.001
|
||||||
│ │ │ └── database%password/ # Secret: database/password
|
│ │ │ └── database%password/ # Secret: database/password
|
||||||
@ -207,6 +233,18 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
- Leverages existing key management workflows
|
- Leverages existing key management workflows
|
||||||
- Strong authentication through GPG
|
- Strong authentication through GPG
|
||||||
|
|
||||||
|
3. **Keychain Unlockers** (macOS only):
|
||||||
|
- Stores unlock keys in macOS Keychain
|
||||||
|
- Protected by system authentication (Touch ID, password)
|
||||||
|
- Automatic unlocking when Keychain is unlocked
|
||||||
|
- Cross-application integration
|
||||||
|
|
||||||
|
4. **Secure Enclave Unlockers** (macOS - planned):
|
||||||
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
|
- Currently partially implemented but non-functional
|
||||||
|
- Requires Apple Developer Program membership and code signing entitlements
|
||||||
|
- Full implementation blocked by entitlement requirements
|
||||||
|
|
||||||
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
|
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
|
||||||
|
|
||||||
#### Secret-specific Keys
|
#### Secret-specific Keys
|
||||||
@ -241,6 +279,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
|
|
||||||
### Hardware Integration
|
### Hardware Integration
|
||||||
- Hardware token support via PGP/GPG integration
|
- Hardware token support via PGP/GPG integration
|
||||||
|
- macOS Keychain integration for system-level security
|
||||||
|
- Secure Enclave support planned (requires Apple Developer Program)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@ -259,6 +299,9 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
|
|||||||
secret list
|
secret list
|
||||||
secret get database/prod/password
|
secret get database/prod/password
|
||||||
secret get services/api/key
|
secret get services/api/key
|
||||||
|
|
||||||
|
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret remove ssh/servers/web01
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-vault Setup
|
### Multi-vault Setup
|
||||||
@ -270,7 +313,7 @@ secret vault create personal
|
|||||||
# Work with work vault
|
# Work with work vault
|
||||||
secret vault select work
|
secret vault select work
|
||||||
echo "work-db-pass" | secret add database/password
|
echo "work-db-pass" | secret add database/password
|
||||||
secret unlockers add passphrase # Add passphrase authentication
|
secret unlocker add passphrase # Add passphrase authentication
|
||||||
|
|
||||||
# Switch to personal vault
|
# Switch to personal vault
|
||||||
secret vault select personal
|
secret vault select personal
|
||||||
@ -278,19 +321,38 @@ echo "personal-email-pass" | secret add email/password
|
|||||||
|
|
||||||
# List all vaults
|
# List all vaults
|
||||||
secret vault list
|
secret vault list
|
||||||
|
|
||||||
|
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret vault remove personal --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Authentication
|
### Advanced Authentication
|
||||||
```bash
|
```bash
|
||||||
# Add multiple unlock methods
|
# Add multiple unlock methods
|
||||||
secret unlockers add passphrase # Password-based
|
secret unlocker add passphrase # Password-based
|
||||||
secret unlockers add pgp --keyid ABCD1234 # GPG key
|
secret unlocker add pgp --keyid ABCD1234 # GPG key
|
||||||
|
secret unlocker add keychain # macOS Keychain (macOS only)
|
||||||
|
|
||||||
# List unlockers
|
# List unlockers
|
||||||
secret unlockers list
|
secret unlocker list
|
||||||
|
|
||||||
# Select a specific unlocker
|
# Select a specific unlocker
|
||||||
secret unlocker select <unlocker-id>
|
secret unlocker select <unlocker-id>
|
||||||
|
|
||||||
|
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
|
||||||
|
secret unlocker remove <unlocker-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Management
|
||||||
|
```bash
|
||||||
|
# List all versions of a secret
|
||||||
|
secret version list database/prod/password
|
||||||
|
|
||||||
|
# Promote an older version to current
|
||||||
|
secret version promote database/prod/password 20231215.001
|
||||||
|
|
||||||
|
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret version remove database/prod/password 20231214.001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Encryption/Decryption with Age Keys
|
### Encryption/Decryption with Age Keys
|
||||||
@ -316,7 +378,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
- **Age Files**: Standard Age encryption format (.age extension)
|
- **Age Files**: Standard Age encryption format (.age extension)
|
||||||
- **Metadata**: JSON format with timestamps and type information
|
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
||||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
@ -325,8 +387,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
||||||
|
|
||||||
### Cross-Platform Support
|
### Cross-Platform Support
|
||||||
- **macOS**: Full support including Keychain integration
|
- **macOS**: Full support including Keychain and planned Secure Enclave integration
|
||||||
- **Linux**: Full support (excluding Keychain features)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
- **Windows**: Basic support (filesystem operations only)
|
- **Windows**: Basic support (filesystem operations only)
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
@ -367,9 +429,19 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers
|
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers
|
||||||
- **Vault Isolation**: Complete separation between different vaults
|
- **Vault Isolation**: Complete separation between different vaults
|
||||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
Made with love and lots of expensive SOTA AI by [sneak](https://sneak.berlin) in Berlin in the summer of 2025.
|
||||||
|
|
||||||
|
Released as a free software gift to the world, no strings attached, under the [WTFPL](https://www.wtfpl.net/) license.
|
||||||
|
|
||||||
|
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
|
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||||
|
|
||||||
|
45
TODO.md
45
TODO.md
@ -4,6 +4,51 @@ This document outlines the bugs, issues, and improvements that need to be
|
|||||||
addressed before the 1.0 release of the secret manager. Items are
|
addressed before the 1.0 release of the secret manager. Items are
|
||||||
prioritized from most critical (top) to least critical (bottom).
|
prioritized from most critical (top) to least critical (bottom).
|
||||||
|
|
||||||
|
## CRITICAL BLOCKERS FOR 1.0 RELEASE
|
||||||
|
|
||||||
|
### Command Injection Vulnerabilities
|
||||||
|
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
|
||||||
|
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
|
||||||
|
|
||||||
|
### Memory Security Critical Issues
|
||||||
|
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
|
||||||
|
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
|
||||||
|
|
||||||
|
### Race Conditions (Data Corruption Risk)
|
||||||
|
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
|
||||||
|
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
|
||||||
|
|
||||||
|
### Input Validation Vulnerabilities
|
||||||
|
- [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding
|
||||||
|
- [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion
|
||||||
|
|
||||||
|
### Timing Attack Vulnerabilities
|
||||||
|
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
|
||||||
|
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
|
||||||
|
|
||||||
|
## CRITICAL MEMORY SECURITY ISSUES
|
||||||
|
|
||||||
|
### Functions accepting bare []byte for sensitive data
|
||||||
|
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
|
||||||
|
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
|
||||||
|
### Functions returning unprotected secrets
|
||||||
|
- [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally
|
||||||
|
- [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer
|
||||||
|
|
||||||
|
### Intermediate string variables for passphrases
|
||||||
|
- [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter
|
||||||
|
- [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer
|
||||||
|
|
||||||
|
### Unprotected buffer.Bytes() usage
|
||||||
|
- [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function
|
||||||
|
- [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes
|
||||||
|
|
||||||
## Code Cleanups
|
## Code Cleanups
|
||||||
|
|
||||||
* we shouldn't be passing around a statedir, it should be read from the
|
* we shouldn't be passing around a statedir, it should be read from the
|
||||||
|
102
coverage.out
Normal file
102
coverage.out
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
mode: set
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:57.41,60.38 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:60.38,61.41 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:65.2,70.3 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:74.50,76.2 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:79.85,81.28 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:81.28,83.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:86.2,87.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:87.16,89.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:92.2,93.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:93.16,95.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:98.2,98.35 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:102.89,105.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:105.16,107.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:110.2,114.21 4 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:118.99,119.46 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:119.46,121.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:124.2,134.39 5 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:134.39,137.15 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:137.15,140.4 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:143.3,145.17 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:145.17,147.4 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.3,150.15 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.15,152.4 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:155.3,156.17 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:156.17,158.4 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:160.3,160.14 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:163.2,163.17 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:167.107,171.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:171.16,173.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:177.2,186.15 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:187.15,188.13 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:189.15,190.13 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:191.15,192.13 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:193.15,194.13 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:195.15,196.13 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:197.10,198.64 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:202.2,204.21 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:208.84,212.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:212.16,214.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:217.2,222.16 4 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:222.16,224.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:226.2,226.26 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:230.99,234.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:234.16,236.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:239.2,251.45 6 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:251.45,253.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:256.2,275.45 12 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:279.39,284.2 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:287.91,288.36 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:288.36,290.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:292.2,295.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:295.16,297.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:300.2,302.41 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:306.100,307.32 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:307.32,309.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:311.2,314.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:314.16,316.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:319.2,325.35 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:325.35,327.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:329.2,329.33 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:333.100,334.32 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:334.32,336.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:338.2,341.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:341.16,343.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:346.2,349.32 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:349.32,351.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:353.2,353.30 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:357.57,375.52 7 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:375.52,381.46 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:381.46,385.4 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:387.3,387.20 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:390.2,390.21 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:394.67,396.2 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:32.22,36.2 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:40.67,41.31 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:41.31,43.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:46.2,55.16 6 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:55.16,57.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:58.2,59.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:59.16,61.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:63.2,63.52 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:68.63,74.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:74.16,76.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:79.2,83.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:83.16,85.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:88.2,91.16 4 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:91.16,93.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:95.2,95.17 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:100.67,103.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:103.16,105.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:108.2,112.16 3 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:112.16,114.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:117.2,120.16 4 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:120.16,122.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:124.2,124.17 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:129.77,131.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:131.16,133.3 1 0
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:135.2,135.33 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:140.81,142.16 2 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:142.16,144.3 1 1
|
||||||
|
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:146.2,146.33 1 1
|
5
go.mod
5
go.mod
@ -9,6 +9,7 @@ require (
|
|||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.14.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
@ -23,7 +24,11 @@ require (
|
|||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
13
go.sum
13
go.sum
@ -43,6 +43,10 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
|
|||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@ -63,7 +67,14 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk=
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ=
|
||||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
@ -117,6 +128,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
|
@ -2,21 +2,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global scanner for consistent stdin reading
|
|
||||||
var stdinScanner *bufio.Scanner //nolint:gochecknoglobals // Needed for consistent stdin handling
|
|
||||||
|
|
||||||
// Instance encapsulates all CLI functionality and state
|
// Instance encapsulates all CLI functionality and state
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
@ -67,33 +57,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
|
|||||||
func (cli *Instance) GetStateDir() string {
|
func (cli *Instance) GetStateDir() string {
|
||||||
return cli.stateDir
|
return cli.stateDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
|
|
||||||
func getStdinScanner() *bufio.Scanner {
|
|
||||||
if stdinScanner == nil {
|
|
||||||
stdinScanner = bufio.NewScanner(os.Stdin)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdinScanner
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLineFromStdin reads a single line from stdin with a prompt
|
|
||||||
// Uses a shared scanner to avoid buffering issues between multiple calls
|
|
||||||
func readLineFromStdin(prompt string) (string, error) {
|
|
||||||
// Check if stderr is a terminal - if not, we can't prompt interactively
|
|
||||||
if !term.IsTerminal(syscall.Stderr) {
|
|
||||||
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
|
||||||
scanner := getStdinScanner()
|
|
||||||
if !scanner.Scan() {
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to read from stdin: EOF")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(scanner.Text()), nil
|
|
||||||
}
|
|
||||||
|
64
internal/cli/completion.go
Normal file
64
internal/cli/completion.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCompletionCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
|
Short: "Generate completion script",
|
||||||
|
Long: `To load completions:
|
||||||
|
|
||||||
|
Bash:
|
||||||
|
$ source <(secret completion bash)
|
||||||
|
# To load completions for each session, execute once:
|
||||||
|
# Linux:
|
||||||
|
$ secret completion bash > /etc/bash_completion.d/secret
|
||||||
|
# macOS:
|
||||||
|
$ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret
|
||||||
|
|
||||||
|
Zsh:
|
||||||
|
# If shell completion is not already enabled in your environment,
|
||||||
|
# you will need to enable it. You can execute the following once:
|
||||||
|
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||||
|
|
||||||
|
# To load completions for each session, execute once:
|
||||||
|
$ secret completion zsh > "${fpath[1]}/_secret"
|
||||||
|
# You will need to start a new shell for this setup to take effect.
|
||||||
|
|
||||||
|
Fish:
|
||||||
|
$ secret completion fish | source
|
||||||
|
# To load completions for each session, execute once:
|
||||||
|
$ secret completion fish > ~/.config/fish/completions/secret.fish
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
PS> secret completion powershell | Out-String | Invoke-Expression
|
||||||
|
# To load completions for every new session, run:
|
||||||
|
PS> secret completion powershell > secret.ps1
|
||||||
|
# and source this file from your PowerShell profile.
|
||||||
|
`,
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
switch args[0] {
|
||||||
|
case "bash":
|
||||||
|
return cmd.Root().GenBashCompletion(os.Stdout)
|
||||||
|
case "zsh":
|
||||||
|
return cmd.Root().GenZshCompletion(os.Stdout)
|
||||||
|
case "fish":
|
||||||
|
return cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||||
|
case "powershell":
|
||||||
|
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported shell type: %s", args[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
144
internal/cli/completions.go
Normal file
144
internal/cli/completions.go
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getSecretNamesCompletionFunc returns a completion function that provides secret names
|
||||||
|
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Get current vault
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of secrets
|
||||||
|
secrets, err := vlt.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter secrets based on what user has typed
|
||||||
|
var completions []string
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if strings.HasPrefix(secret, toComplete) {
|
||||||
|
completions = append(completions, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
|
||||||
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Get current vault
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unlocker metadata list
|
||||||
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vault directory
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unlocker IDs
|
||||||
|
var completions []string
|
||||||
|
|
||||||
|
for _, metadata := range unlockerMetadataList {
|
||||||
|
// Get the actual unlocker ID by creating the unlocker instance
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
files, err := afero.ReadDir(fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
// Check if this is the right unlocker by comparing metadata
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
// Create the appropriate unlocker instance
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
switch metadata.Type {
|
||||||
|
case "passphrase":
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
case "keychain":
|
||||||
|
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
case "pgp":
|
||||||
|
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker != nil {
|
||||||
|
id := unlocker.GetID()
|
||||||
|
if strings.HasPrefix(id, toComplete) {
|
||||||
|
completions = append(completions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVaultNamesCompletionFunc returns a completion function that provides vault names
|
||||||
|
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
var completions []string
|
||||||
|
for _, v := range vaults {
|
||||||
|
if strings.HasPrefix(v, toComplete) {
|
||||||
|
completions = append(completions, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
@ -96,21 +96,13 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Secret exists, get the age secret key from it
|
// Secret exists, get the age secret key from it
|
||||||
secretValue, err := cli.getSecretValue(vlt, secretObj)
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
// Create secure buffer for the secret value
|
ageSecretKey = secretBuffer.String()
|
||||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secureBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original secret value
|
|
||||||
for i := range secretValue {
|
|
||||||
secretValue[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ageSecretKey = secureBuffer.String()
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(ageSecretKey) {
|
if !isValidAgeSecretKey(ageSecretKey) {
|
||||||
@ -189,36 +181,28 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the age secret key from the secret
|
// Get the age secret key from the secret
|
||||||
var secretValue []byte
|
var secretBuffer *memguard.LockedBuffer
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
secretValue, err = secretObj.GetValue(nil)
|
secretBuffer, err = secretObj.GetValue(nil)
|
||||||
} else {
|
} else {
|
||||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||||
if unlockErr != nil {
|
if unlockErr != nil {
|
||||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||||
}
|
}
|
||||||
secretValue, err = secretObj.GetValue(unlocker)
|
secretBuffer, err = secretObj.GetValue(unlocker)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
// Create secure buffer for the secret value
|
|
||||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secureBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original secret value
|
|
||||||
for i := range secretValue {
|
|
||||||
secretValue[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(secureBuffer.String()) {
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the age secret key to get the identity
|
// Parse the age secret key to get the identity
|
||||||
identity, err := age.ParseX25519Identity(secureBuffer.String())
|
identity, err := age.ParseX25519Identity(secretBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||||
}
|
}
|
||||||
@ -266,7 +250,7 @@ func isValidAgeSecretKey(key string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||||
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
|
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
return secretObj.GetValue(nil)
|
return secretObj.GetValue(nil)
|
||||||
}
|
}
|
||||||
|
161
internal/cli/info.go
Normal file
161
internal/cli/info.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version info - these are set at build time
|
||||||
|
var ( //nolint:gochecknoglobals // Set at build time
|
||||||
|
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||||
|
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
||||||
|
)
|
||||||
|
|
||||||
|
// InfoOutput represents the system information for JSON output
|
||||||
|
type InfoOutput struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GitCommit string `json:"gitCommit"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
License string `json:"license"`
|
||||||
|
GoVersion string `json:"goVersion"`
|
||||||
|
DataDirectory string `json:"dataDirectory"`
|
||||||
|
CurrentVault string `json:"currentVault"`
|
||||||
|
NumVaults int `json:"numVaults"`
|
||||||
|
NumSecrets int `json:"numSecrets"`
|
||||||
|
TotalSize int64 `json:"totalSizeBytes"`
|
||||||
|
OldestSecret time.Time `json:"oldestSecret,omitempty"`
|
||||||
|
LatestSecret time.Time `json:"latestSecret,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInfoCmd returns the info command
|
||||||
|
func newInfoCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
var jsonOutput bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "info",
|
||||||
|
Short: "Display system information",
|
||||||
|
Long: "Display information about the secret system including version, vault statistics, and storage usage",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cli.Info(cmd, jsonOutput)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info displays system information
|
||||||
|
func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||||
|
info := InfoOutput{
|
||||||
|
Version: Version,
|
||||||
|
GitCommit: GitCommit,
|
||||||
|
Author: "Jeffrey Paul <sneak@sneak.berlin>",
|
||||||
|
License: "WTFPL",
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
DataDirectory: cli.stateDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current vault
|
||||||
|
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err == nil {
|
||||||
|
info.CurrentVault = currentVault.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count vaults
|
||||||
|
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||||
|
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, entry := range vaultEntries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
info.NumVaults++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather statistics from all vaults
|
||||||
|
if info.NumVaults > 0 {
|
||||||
|
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(cli.fs, vaultsDir)
|
||||||
|
info.NumSecrets = totalSecrets
|
||||||
|
info.TotalSize = totalSize
|
||||||
|
if !oldestTime.IsZero() {
|
||||||
|
info.OldestSecret = oldestTime
|
||||||
|
}
|
||||||
|
if !latestTime.IsZero() {
|
||||||
|
info.LatestSecret = latestTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
encoder := json.NewEncoder(cmd.OutOrStdout())
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
|
||||||
|
return encoder.Encode(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty print with colors and emoji
|
||||||
|
return prettyPrintInfo(cmd.OutOrStdout(), info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettyPrintInfo formats and prints the info in a pretty format
|
||||||
|
func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
||||||
|
const separatorLength = 40
|
||||||
|
|
||||||
|
bold := color.New(color.Bold)
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
cyan := color.New(color.FgCyan)
|
||||||
|
yellow := color.New(color.FgYellow)
|
||||||
|
magenta := color.New(color.FgMagenta)
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(w)
|
||||||
|
_, _ = bold.Fprintln(w, "🔐 Secret System Information")
|
||||||
|
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "📦 Version: %s\n", green.Sprint(info.Version))
|
||||||
|
_, _ = fmt.Fprintf(w, "🔧 Git Commit: %s\n", cyan.Sprint(info.GitCommit))
|
||||||
|
_, _ = fmt.Fprintf(w, "👤 Author: %s\n", cyan.Sprint(info.Author))
|
||||||
|
_, _ = fmt.Fprintf(w, "📜 License: %s\n", cyan.Sprint(info.License))
|
||||||
|
_, _ = fmt.Fprintf(w, "🐹 Go Version: %s\n", cyan.Sprint(info.GoVersion))
|
||||||
|
_, _ = fmt.Fprintf(w, "📁 Data Directory: %s\n", yellow.Sprint(info.DataDirectory))
|
||||||
|
|
||||||
|
if info.CurrentVault != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", magenta.Sprint(info.CurrentVault))
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", color.RedString("(none)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
||||||
|
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
||||||
|
if info.TotalSize >= 0 {
|
||||||
|
//nolint:gosec // TotalSize is always >= 0
|
||||||
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.OldestSecret.IsZero() {
|
||||||
|
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n", info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
if !info.LatestSecret.IsZero() {
|
||||||
|
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(w)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
83
internal/cli/info_helper.go
Normal file
83
internal/cli/info_helper.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatherVaultStats collects statistics from all vaults
|
||||||
|
func gatherVaultStats(
|
||||||
|
fs afero.Fs,
|
||||||
|
vaultsDir string,
|
||||||
|
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) {
|
||||||
|
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, time.Time{}, time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, vaultEntry := range vaultEntries {
|
||||||
|
if !vaultEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultPath := filepath.Join(vaultsDir, vaultEntry.Name())
|
||||||
|
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||||
|
|
||||||
|
// Count secrets in this vault
|
||||||
|
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secretEntry := range secretEntries {
|
||||||
|
if !secretEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSecrets++
|
||||||
|
secretPath := filepath.Join(secretsPath, secretEntry.Name())
|
||||||
|
|
||||||
|
// Get size and timestamps from all versions
|
||||||
|
versionsPath := filepath.Join(secretPath, "versions")
|
||||||
|
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, versionEntry := range versionEntries {
|
||||||
|
if !versionEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPath := filepath.Join(versionsPath, versionEntry.Name())
|
||||||
|
|
||||||
|
// Add size of encrypted data
|
||||||
|
dataPath := filepath.Join(versionPath, "data.age")
|
||||||
|
if stat, err := fs.Stat(dataPath); err == nil {
|
||||||
|
totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add size of metadata
|
||||||
|
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||||
|
if stat, err := fs.Stat(metaPath); err == nil {
|
||||||
|
totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track timestamps
|
||||||
|
if stat, err := fs.Stat(versionPath); err == nil {
|
||||||
|
modTime := stat.ModTime()
|
||||||
|
if oldestTime.IsZero() || modTime.Before(oldestTime) {
|
||||||
|
oldestTime = modTime
|
||||||
|
}
|
||||||
|
if latestTime.IsZero() || modTime.After(latestTime) {
|
||||||
|
latestTime = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSecrets, totalSize, oldestTime, latestTime, nil
|
||||||
|
}
|
@ -60,14 +60,17 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
mnemonicStr = envMnemonic
|
mnemonicStr = envMnemonic
|
||||||
} else {
|
} else {
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
// Read mnemonic from stdin using shared line reader
|
// Read mnemonic securely without echo
|
||||||
var err error
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||||
mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
}
|
}
|
||||||
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
@ -202,20 +205,26 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer passphraseBuffer1.Destroy()
|
|
||||||
|
|
||||||
// Read confirmation passphrase
|
// Read confirmation passphrase
|
||||||
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
passphraseBuffer1.Destroy()
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
||||||
}
|
}
|
||||||
defer passphraseBuffer2.Destroy()
|
|
||||||
|
|
||||||
// Compare passphrases
|
// Compare passphrases
|
||||||
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
||||||
|
passphraseBuffer1.Destroy()
|
||||||
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
return nil, fmt.Errorf("passphrases do not match")
|
return nil, fmt.Errorf("passphrases do not match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new buffer with the confirmed passphrase
|
// Clean up the second buffer, we'll return the first
|
||||||
return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
|
// Return the first buffer (caller is responsible for destroying it)
|
||||||
|
return passphraseBuffer1, nil
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// testMnemonic is a standard BIP39 mnemonic used for testing
|
||||||
|
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
)
|
||||||
|
|
||||||
// TestMain runs before all tests and ensures the binary is built
|
// TestMain runs before all tests and ensures the binary is built
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// Get the current working directory
|
// Get the current working directory
|
||||||
@ -60,7 +65,6 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test configuration
|
// Test configuration
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
testPassphrase := "test-passphrase-123"
|
testPassphrase := "test-passphrase-123"
|
||||||
|
|
||||||
// Create a temporary directory for our vault
|
// Create a temporary directory for our vault
|
||||||
@ -125,7 +129,8 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// - work vault has pub.age file
|
// - work vault has pub.age file
|
||||||
// - work vault has unlockers.d/passphrase directory
|
// - work vault has unlockers.d/passphrase directory
|
||||||
// - Unlocker metadata and encrypted keys present
|
// - Unlocker metadata and encrypted keys present
|
||||||
test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
|
// NOTE: Skipped because vault creation now includes mnemonic import
|
||||||
|
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
|
||||||
|
|
||||||
// Test 5: Add secrets with versioning
|
// Test 5: Add secrets with versioning
|
||||||
// Command: echo "password123" | secret add database/password
|
// Command: echo "password123" | secret add database/password
|
||||||
@ -177,14 +182,26 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// Expected: Shows database/password with metadata
|
// Expected: Shows database/password with metadata
|
||||||
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
|
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||||
|
|
||||||
|
// Test 11b: List secrets with quiet flag
|
||||||
|
// Command: secret list -q
|
||||||
|
// Purpose: Test quiet output for scripting
|
||||||
|
// Expected: Only secret names, no headers or formatting
|
||||||
|
test11bListSecretsQuiet(t, testMnemonic, runSecret)
|
||||||
|
|
||||||
// Test 12: Add secrets with different name formats
|
// Test 12: Add secrets with different name formats
|
||||||
// Commands: Various secret names (paths, dots, underscores)
|
// Commands: Various secret names (paths, dots, underscores)
|
||||||
// Purpose: Test secret name validation and storage encoding
|
// Purpose: Test secret name validation and storage encoding
|
||||||
// Expected: Proper filesystem encoding (/ -> %)
|
// Expected: Proper filesystem encoding (/ -> %)
|
||||||
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
||||||
|
|
||||||
|
// Test 12b: Move/rename secrets
|
||||||
|
// Commands: secret move, secret mv, secret rename
|
||||||
|
// Purpose: Test moving and renaming secrets
|
||||||
|
// Expected: Secret moved to new location, old location removed
|
||||||
|
test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||||
|
|
||||||
// Test 13: Unlocker management
|
// Test 13: Unlocker management
|
||||||
// Commands: secret unlockers list, secret unlockers add pgp
|
// Commands: secret unlocker list, secret unlocker add pgp
|
||||||
// Purpose: Test multiple unlocker types
|
// Purpose: Test multiple unlocker types
|
||||||
// Expected filesystem:
|
// Expected filesystem:
|
||||||
// - Multiple directories under unlockers.d/
|
// - Multiple directories under unlockers.d/
|
||||||
@ -440,6 +457,12 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
|
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
|
||||||
|
// Set environment variables for vault creation
|
||||||
|
os.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
|
os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase")
|
||||||
|
defer os.Unsetenv("SB_SECRET_MNEMONIC")
|
||||||
|
defer os.Unsetenv("SB_UNLOCK_PASSPHRASE")
|
||||||
|
|
||||||
// Create work vault
|
// Create work vault
|
||||||
output, err := runSecret("vault", "create", "work")
|
output, err := runSecret("vault", "create", "work")
|
||||||
require.NoError(t, err, "vault create should succeed")
|
require.NoError(t, err, "vault create should succeed")
|
||||||
@ -468,9 +491,9 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
|
|||||||
secretsDir := filepath.Join(workVaultDir, "secrets.d")
|
secretsDir := filepath.Join(workVaultDir, "secrets.d")
|
||||||
verifyFileExists(t, secretsDir)
|
verifyFileExists(t, secretsDir)
|
||||||
|
|
||||||
// Verify that work vault does NOT have a long-term key yet (no mnemonic imported)
|
// Verify that work vault has a long-term key (mnemonic was provided)
|
||||||
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
|
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
|
||||||
verifyFileNotExists(t, pubKeyFile)
|
verifyFileExists(t, pubKeyFile)
|
||||||
|
|
||||||
// List vaults to verify both exist
|
// List vaults to verify both exist
|
||||||
output, err = runSecret("vault", "list")
|
output, err = runSecret("vault", "list")
|
||||||
@ -901,6 +924,81 @@ func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...stri
|
|||||||
assert.True(t, secretNames["database/password"], "should have database/password")
|
assert.True(t, secretNames["database/password"], "should have database/password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test11bListSecretsQuiet(t *testing.T, testMnemonic string, runSecret func(...string) (string, error)) {
|
||||||
|
// Test quiet output
|
||||||
|
quietOutput, err := runSecret("list", "-q")
|
||||||
|
require.NoError(t, err, "secret list -q should succeed")
|
||||||
|
|
||||||
|
// Split output into lines
|
||||||
|
lines := strings.Split(strings.TrimSpace(quietOutput), "\n")
|
||||||
|
|
||||||
|
// Should have exactly 3 lines (3 secrets)
|
||||||
|
assert.Len(t, lines, 3, "quiet output should have exactly 3 lines")
|
||||||
|
|
||||||
|
// Should not contain any headers or formatting
|
||||||
|
assert.NotContains(t, quietOutput, "Secrets in vault", "should not have vault header")
|
||||||
|
assert.NotContains(t, quietOutput, "NAME", "should not have NAME header")
|
||||||
|
assert.NotContains(t, quietOutput, "LAST UPDATED", "should not have LAST UPDATED header")
|
||||||
|
assert.NotContains(t, quietOutput, "Total:", "should not have total count")
|
||||||
|
assert.NotContains(t, quietOutput, "----", "should not have separator lines")
|
||||||
|
|
||||||
|
// Should contain exactly the secret names
|
||||||
|
secretNames := make(map[string]bool)
|
||||||
|
for _, line := range lines {
|
||||||
|
secretNames[line] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, secretNames["api/key"], "should have api/key")
|
||||||
|
assert.True(t, secretNames["config/database.yaml"], "should have config/database.yaml")
|
||||||
|
assert.True(t, secretNames["database/password"], "should have database/password")
|
||||||
|
|
||||||
|
// Test quiet output with filter
|
||||||
|
quietFilterOutput, err := runSecret("list", "database", "-q")
|
||||||
|
require.NoError(t, err, "secret list with filter and -q should succeed")
|
||||||
|
|
||||||
|
// Should only show secrets matching filter
|
||||||
|
filteredLines := strings.Split(strings.TrimSpace(quietFilterOutput), "\n")
|
||||||
|
assert.Len(t, filteredLines, 2, "quiet filtered output should have exactly 2 lines")
|
||||||
|
|
||||||
|
// Verify filtered results
|
||||||
|
filteredSecrets := make(map[string]bool)
|
||||||
|
for _, line := range filteredLines {
|
||||||
|
filteredSecrets[line] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, filteredSecrets["config/database.yaml"], "should have config/database.yaml")
|
||||||
|
assert.True(t, filteredSecrets["database/password"], "should have database/password")
|
||||||
|
assert.False(t, filteredSecrets["api/key"], "should not have api/key")
|
||||||
|
|
||||||
|
// Test that quiet and JSON flags are mutually exclusive behavior
|
||||||
|
// (JSON should take precedence if both are specified)
|
||||||
|
jsonQuietOutput, err := runSecret("list", "--json", "-q")
|
||||||
|
require.NoError(t, err, "secret list --json -q should succeed")
|
||||||
|
|
||||||
|
// Should be valid JSON, not quiet output
|
||||||
|
var jsonResponse map[string]interface{}
|
||||||
|
err = json.Unmarshal([]byte(jsonQuietOutput), &jsonResponse)
|
||||||
|
assert.NoError(t, err, "output should be valid JSON when both flags are used")
|
||||||
|
|
||||||
|
// Test using quiet output in command substitution would work like:
|
||||||
|
// secret get $(secret list -q | head -1)
|
||||||
|
// We'll simulate this by getting the first secret name
|
||||||
|
firstSecret := lines[0]
|
||||||
|
|
||||||
|
// Need to create a runSecretWithEnv to provide mnemonic for get operation
|
||||||
|
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
|
||||||
|
return cli.ExecuteCommandInProcess(args, "", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", firstSecret)
|
||||||
|
require.NoError(t, err, "get with secret name from quiet output should succeed")
|
||||||
|
|
||||||
|
// Verify we got a value (not empty)
|
||||||
|
assert.NotEmpty(t, getOutput, "should retrieve a non-empty secret value")
|
||||||
|
}
|
||||||
|
|
||||||
func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
// Make sure we're in default vault
|
// Make sure we're in default vault
|
||||||
runSecret := func(args ...string) (string, error) {
|
runSecret := func(args ...string) (string, error) {
|
||||||
@ -1010,15 +1108,89 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
|
// First, create a secret to move
|
||||||
|
_, err := runSecretWithStdin("original-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/original")
|
||||||
|
require.NoError(t, err, "add test/original should succeed")
|
||||||
|
|
||||||
|
// Test move command
|
||||||
|
output, err := runSecret("move", "test/original", "test/renamed")
|
||||||
|
require.NoError(t, err, "move should succeed")
|
||||||
|
assert.Contains(t, output, "Moved secret 'test/original' to 'test/renamed'", "should show move confirmation")
|
||||||
|
|
||||||
|
// Need to create a runSecretWithEnv for get operations
|
||||||
|
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
|
||||||
|
return cli.ExecuteCommandInProcess(args, "", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify original doesn't exist
|
||||||
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/original")
|
||||||
|
assert.Error(t, err, "get original should fail after move")
|
||||||
|
|
||||||
|
// Verify new location exists and has correct value
|
||||||
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/renamed")
|
||||||
|
require.NoError(t, err, "get renamed should succeed")
|
||||||
|
assert.Equal(t, "original-value", getOutput, "renamed secret should have original value")
|
||||||
|
|
||||||
|
// Test mv alias
|
||||||
|
_, err = runSecretWithStdin("another-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/another")
|
||||||
|
require.NoError(t, err, "add test/another should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("mv", "test/another", "test/moved-with-mv")
|
||||||
|
require.NoError(t, err, "mv alias should work")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Test rename alias
|
||||||
|
_, err = runSecretWithStdin("rename-test-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/rename-me")
|
||||||
|
require.NoError(t, err, "add test/rename-me should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("rename", "test/rename-me", "test/renamed-with-alias")
|
||||||
|
require.NoError(t, err, "rename alias should work")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Test error cases
|
||||||
|
// Try to move non-existent secret
|
||||||
|
output, err = runSecret("move", "test/nonexistent", "test/destination")
|
||||||
|
assert.Error(t, err, "move non-existent should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate source not found")
|
||||||
|
|
||||||
|
// Try to move to existing destination
|
||||||
|
_, err = runSecretWithStdin("dest-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/existing-dest")
|
||||||
|
require.NoError(t, err, "add test/existing-dest should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("move", "test/renamed", "test/existing-dest")
|
||||||
|
assert.Error(t, err, "move to existing destination should fail")
|
||||||
|
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||||
|
|
||||||
|
// Verify the source wasn't removed since move failed
|
||||||
|
getOutput, err = runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/renamed")
|
||||||
|
require.NoError(t, err, "get source should still work after failed move")
|
||||||
|
assert.Equal(t, "original-value", getOutput, "source should still have original value")
|
||||||
|
}
|
||||||
|
|
||||||
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
// Make sure we're in default vault
|
// Make sure we're in default vault
|
||||||
_, err := runSecret("vault", "select", "default")
|
_, err := runSecret("vault", "select", "default")
|
||||||
require.NoError(t, err, "vault select should succeed")
|
require.NoError(t, err, "vault select should succeed")
|
||||||
|
|
||||||
// List unlockers
|
// List unlockers
|
||||||
output, err := runSecret("unlockers", "list")
|
output, err := runSecret("unlocker", "list")
|
||||||
require.NoError(t, err, "unlockers list should succeed")
|
require.NoError(t, err, "unlocker list should succeed")
|
||||||
t.Logf("DEBUG: unlockers list output: %q", output)
|
t.Logf("DEBUG: unlocker list output: %q", output)
|
||||||
|
|
||||||
// Should have the passphrase unlocker created during init
|
// Should have the passphrase unlocker created during init
|
||||||
assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
|
assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
|
||||||
@ -1027,15 +1199,15 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
|
|||||||
output, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
"SB_UNLOCK_PASSPHRASE": "another-passphrase",
|
"SB_UNLOCK_PASSPHRASE": "another-passphrase",
|
||||||
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
|
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
|
||||||
}, "unlockers", "add", "passphrase")
|
}, "unlocker", "add", "passphrase")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
|
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
|
||||||
}
|
}
|
||||||
require.NoError(t, err, "add passphrase unlocker should succeed")
|
require.NoError(t, err, "add passphrase unlocker should succeed")
|
||||||
|
|
||||||
// List unlockers again - should have 2 now
|
// List unlockers again - should have 2 now
|
||||||
output, err = runSecret("unlockers", "list")
|
output, err = runSecret("unlocker", "list")
|
||||||
require.NoError(t, err, "unlockers list should succeed")
|
require.NoError(t, err, "unlocker list should succeed")
|
||||||
|
|
||||||
// Count passphrase unlockers
|
// Count passphrase unlockers
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
@ -1051,8 +1223,8 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
|
|||||||
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
|
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
|
||||||
|
|
||||||
// Test JSON output
|
// Test JSON output
|
||||||
jsonOutput, err := runSecret("unlockers", "list", "--json")
|
jsonOutput, err := runSecret("unlocker", "list", "--json")
|
||||||
require.NoError(t, err, "unlockers list --json should succeed")
|
require.NoError(t, err, "unlocker list --json should succeed")
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
err = json.Unmarshal([]byte(jsonOutput), &response)
|
err = json.Unmarshal([]byte(jsonOutput), &response)
|
||||||
@ -1536,10 +1708,10 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
|
|||||||
|
|
||||||
// Test secret list --json (already tested in test 11)
|
// Test secret list --json (already tested in test 11)
|
||||||
|
|
||||||
// Test unlockers list --json (already tested in test 13)
|
// Test unlocker list --json (already tested in test 13)
|
||||||
|
|
||||||
// All JSON outputs verified to be valid and contain expected fields
|
// All JSON outputs verified to be valid and contain expected fields
|
||||||
t.Log("JSON output formats verified for vault list, secret list, and unlockers list")
|
t.Log("JSON output formats verified for vault list, secret list, and unlocker list")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
|
@ -34,12 +34,15 @@ func newRootCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newAddCmd())
|
cmd.AddCommand(newAddCmd())
|
||||||
cmd.AddCommand(newGetCmd())
|
cmd.AddCommand(newGetCmd())
|
||||||
cmd.AddCommand(newListCmd())
|
cmd.AddCommand(newListCmd())
|
||||||
cmd.AddCommand(newUnlockersCmd())
|
cmd.AddCommand(newRemoveCmd())
|
||||||
|
cmd.AddCommand(newMoveCmd())
|
||||||
cmd.AddCommand(newUnlockerCmd())
|
cmd.AddCommand(newUnlockerCmd())
|
||||||
cmd.AddCommand(newImportCmd())
|
cmd.AddCommand(newImportCmd())
|
||||||
cmd.AddCommand(newEncryptCmd())
|
cmd.AddCommand(newEncryptCmd())
|
||||||
cmd.AddCommand(newDecryptCmd())
|
cmd.AddCommand(newDecryptCmd())
|
||||||
cmd.AddCommand(newVersionCmd())
|
cmd.AddCommand(newVersionCmd())
|
||||||
|
cmd.AddCommand(newInfoCmd())
|
||||||
|
cmd.AddCommand(newCompletionCmd())
|
||||||
|
|
||||||
secret.Debug("newRootCmd completed")
|
secret.Debug("newRootCmd completed")
|
||||||
|
|
||||||
|
@ -4,11 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,10 +39,12 @@ func newAddCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newGetCmd() *cobra.Command {
|
func newGetCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "get <secret-name>",
|
Use: "get <secret-name>",
|
||||||
Short: "Retrieve a secret from the vault",
|
Short: "Retrieve a secret from the vault",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
version, _ := cmd.Flags().GetString("version")
|
version, _ := cmd.Flags().GetString("version")
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
@ -63,6 +67,7 @@ func newListCmd() *cobra.Command {
|
|||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
||||||
|
|
||||||
var filter string
|
var filter string
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@ -71,11 +76,12 @@ func newListCmd() *cobra.Command {
|
|||||||
|
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return cli.ListSecrets(cmd, jsonOutput, filter)
|
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||||
|
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@ -103,6 +109,53 @@ func newImportCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newRemoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <secret-name>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a secret from the vault",
|
||||||
|
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
||||||
|
`cannot be undone.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
return cli.RemoveSecret(cmd, args[0], false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "move <source> <destination>",
|
||||||
|
Aliases: []string{"mv", "rename"},
|
||||||
|
Short: "Move or rename a secret",
|
||||||
|
Long: `Move or rename a secret within the current vault. ` +
|
||||||
|
`If the destination already exists, the operation will fail.`,
|
||||||
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Only complete the first argument (source)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
return cli.MoveSecret(cmd, args[0], args[1])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// updateBufferSize updates the buffer size based on usage pattern
|
// updateBufferSize updates the buffer size based on usage pattern
|
||||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||||
*sameSize++
|
*sameSize++
|
||||||
@ -264,7 +317,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListSecrets lists all secrets in the current vault
|
// ListSecrets lists all secrets in the current vault
|
||||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error {
|
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -320,15 +373,21 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
|
|||||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println(string(jsonBytes))
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||||
|
} else if quietOutput {
|
||||||
|
// Quiet output - just secret names
|
||||||
|
for _, secretName := range filteredSecrets {
|
||||||
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Pretty table output
|
// Pretty table output
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
if len(filteredSecrets) == 0 {
|
if len(filteredSecrets) == 0 {
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
|
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
|
||||||
} else {
|
} else {
|
||||||
cmd.Println("No secrets found in current vault.")
|
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||||
cmd.Println("Run 'secret add <name>' to create one.")
|
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -336,12 +395,25 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
|
|||||||
|
|
||||||
// Get current vault name for display
|
// Get current vault name for display
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
|
||||||
} else {
|
} else {
|
||||||
cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||||
}
|
}
|
||||||
cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
|
|
||||||
cmd.Printf("%-40s %-20s\n", "----", "------------")
|
// Calculate the maximum name length for proper column alignment
|
||||||
|
maxNameLen := len("NAME") // Start with header length
|
||||||
|
for _, secretName := range filteredSecrets {
|
||||||
|
if len(secretName) > maxNameLen {
|
||||||
|
maxNameLen = len(secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add some padding
|
||||||
|
maxNameLen += 2
|
||||||
|
|
||||||
|
// Print headers with dynamic width
|
||||||
|
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
||||||
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
||||||
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------")
|
||||||
|
|
||||||
for _, secretName := range filteredSecrets {
|
for _, secretName := range filteredSecrets {
|
||||||
lastUpdated := "unknown"
|
lastUpdated := "unknown"
|
||||||
@ -349,14 +421,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
|
|||||||
metadata := secretObj.GetMetadata()
|
metadata := secretObj.GetMetadata()
|
||||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
cmd.Printf("%-40s %-20s\n", secretName, lastUpdated)
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
|
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
cmd.Printf(" (filtered from %d)", len(secrets))
|
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
|
||||||
}
|
}
|
||||||
cmd.Println()
|
_, _ = fmt.Fprintln(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -448,3 +520,93 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveSecret removes a secret from the vault
|
||||||
|
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
||||||
|
// Get current vault
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count versions for information
|
||||||
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
versionCount := 0
|
||||||
|
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||||
|
versionCount = len(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the secret directory
|
||||||
|
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveSecret moves or renames a secret
|
||||||
|
func (cli *Instance) MoveSecret(cmd *cobra.Command, sourceName, destName string) error {
|
||||||
|
// Get current vault
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vault directory
|
||||||
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
sourceEncoded := strings.ReplaceAll(sourceName, "/", "%")
|
||||||
|
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", sourceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
destEncoded := strings.ReplaceAll(destName, "/", "%")
|
||||||
|
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(cli.fs, destDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return fmt.Errorf("secret '%s' already exists", destName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the move
|
||||||
|
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to move secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Moved secret '%s' to '%s'\n", sourceName, destName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -15,28 +17,82 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Import from init.go
|
// UnlockerInfo represents unlocker information for display
|
||||||
|
type UnlockerInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Flags []string `json:"flags,omitempty"`
|
||||||
|
IsCurrent bool `json:"isCurrent"`
|
||||||
|
}
|
||||||
|
|
||||||
// ... existing imports ...
|
// Table formatting constants
|
||||||
|
const (
|
||||||
|
unlockerIDWidth = 40
|
||||||
|
unlockerTypeWidth = 12
|
||||||
|
unlockerDateWidth = 20
|
||||||
|
unlockerFlagsWidth = 20
|
||||||
|
)
|
||||||
|
|
||||||
func newUnlockersCmd() *cobra.Command {
|
// getDefaultGPGKey returns the default GPG key ID if available
|
||||||
|
func getDefaultGPGKey() (string, error) {
|
||||||
|
// First try to get the configured default key using gpgconf
|
||||||
|
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||||
|
// The default key is in field 10 (index 9)
|
||||||
|
return fields[9], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no default key is configured, get the first secret key
|
||||||
|
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to find the first usable secret key
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
// sec line indicates a secret key
|
||||||
|
if strings.HasPrefix(line, "sec:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
// Field 5 contains the key ID
|
||||||
|
if len(fields) > 4 && fields[4] != "" {
|
||||||
|
return fields[4], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no GPG secret keys found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnlockerCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "unlockers",
|
Use: "unlocker",
|
||||||
Short: "Manage unlockers",
|
Short: "Manage unlockers",
|
||||||
Long: `Create, list, and remove unlockers for the current vault.`,
|
Long: `Create, list, and remove unlockers for the current vault.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newUnlockersListCmd())
|
cmd.AddCommand(newUnlockerListCmd())
|
||||||
cmd.AddCommand(newUnlockersAddCmd())
|
cmd.AddCommand(newUnlockerAddCmd())
|
||||||
cmd.AddCommand(newUnlockersRmCmd())
|
cmd.AddCommand(newUnlockerRemoveCmd())
|
||||||
|
cmd.AddCommand(newUnlockerSelectCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersListCmd() *cobra.Command {
|
func newUnlockerListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List unlockers in the current vault",
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List unlockers in the current vault",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
@ -52,54 +108,115 @@ func newUnlockersListCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersAddCmd() *cobra.Command {
|
func newUnlockerAddCmd() *cobra.Command {
|
||||||
|
// Build the supported types list based on platform
|
||||||
|
supportedTypes := "passphrase, pgp"
|
||||||
|
typeDescriptions := `Available unlocker types:
|
||||||
|
|
||||||
|
passphrase - Traditional password-based encryption
|
||||||
|
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
||||||
|
The passphrase is never stored in plaintext.
|
||||||
|
|
||||||
|
pgp - GNU Privacy Guard (GPG) key-based encryption
|
||||||
|
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
||||||
|
Requires gpg to be installed and configured with at least one secret key.
|
||||||
|
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
supportedTypes = "passphrase, keychain, pgp"
|
||||||
|
typeDescriptions = `Available unlocker types:
|
||||||
|
|
||||||
|
passphrase - Traditional password-based encryption
|
||||||
|
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
||||||
|
The passphrase is never stored in plaintext.
|
||||||
|
|
||||||
|
keychain - macOS Keychain integration (macOS only)
|
||||||
|
Stores the vault's master key in the macOS Keychain, protected by your login password.
|
||||||
|
Automatically unlocks when your Keychain is unlocked (e.g., after login).
|
||||||
|
Provides seamless integration with macOS security features like Touch ID.
|
||||||
|
|
||||||
|
pgp - GNU Privacy Guard (GPG) key-based encryption
|
||||||
|
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
||||||
|
Requires gpg to be installed and configured with at least one secret key.
|
||||||
|
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
|
||||||
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <type>",
|
Use: "add <type>",
|
||||||
Short: "Add a new unlocker",
|
Short: "Add a new unlocker",
|
||||||
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
|
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
|
||||||
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]
|
||||||
|
|
||||||
return cli.UnlockersAdd(args[0], cmd)
|
// Validate unlocker type
|
||||||
|
validTypes := strings.Split(supportedTypes, ", ")
|
||||||
|
valid := false
|
||||||
|
for _, t := range validTypes {
|
||||||
|
if unlockerType == t {
|
||||||
|
valid = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
|
||||||
|
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if --keyid was used with non-PGP type
|
||||||
|
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
||||||
|
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.UnlockersAdd(unlockerType, cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
|
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersRmCmd() *cobra.Command {
|
func newUnlockerRemoveCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cli := NewCLIInstance()
|
||||||
Use: "rm <unlocker-id>",
|
cmd := &cobra.Command{
|
||||||
Short: "Remove an unlocker",
|
Use: "remove <unlocker-id>",
|
||||||
Args: cobra.ExactArgs(1),
|
Aliases: []string{"rm"},
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
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()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return cli.UnlockersRemove(args[0])
|
return cli.UnlockersRemove(args[0], force, cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func newUnlockerCmd() *cobra.Command {
|
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "unlocker",
|
|
||||||
Short: "Manage current unlocker",
|
|
||||||
Long: `Select the current unlocker for operations.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.AddCommand(newUnlockerSelectSubCmd())
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerSelectSubCmd() *cobra.Command {
|
func newUnlockerSelectCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "select <unlocker-id>",
|
Use: "select <unlocker-id>",
|
||||||
Short: "Select an unlocker as current",
|
Short: "Select an unlocker as current",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
@ -116,6 +233,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the current unlocker ID
|
||||||
|
var currentUnlockerID string
|
||||||
|
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
if err == nil {
|
||||||
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
|
}
|
||||||
|
|
||||||
// Get the metadata first
|
// Get the metadata first
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -123,13 +247,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
// Load actual unlocker objects to get the proper IDs
|
||||||
type UnlockerInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Flags []string `json:"flags,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, metadata := range unlockerMetadataList {
|
||||||
// Create unlocker instance to get the proper ID
|
// Create unlocker instance to get the proper ID
|
||||||
@ -195,54 +312,79 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
|
IsCurrent: properID == currentUnlockerID,
|
||||||
}
|
}
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
unlockers = append(unlockers, unlockerInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
// JSON output
|
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
|
||||||
output := map[string]interface{}{
|
|
||||||
"unlockers": 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 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))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersAdd adds a new unlocker
|
// UnlockersAdd adds a new unlocker
|
||||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||||
|
// Build the supported types list based on platform
|
||||||
|
supportedTypes := "passphrase, pgp"
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
supportedTypes = "passphrase, keychain, pgp"
|
||||||
|
}
|
||||||
|
|
||||||
switch unlockerType {
|
switch unlockerType {
|
||||||
case "passphrase":
|
case "passphrase":
|
||||||
// Get current vault
|
// Get current vault
|
||||||
@ -274,9 +416,20 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
|
|
||||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
|
||||||
|
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "keychain":
|
case "keychain":
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||||
@ -287,17 +440,52 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
|
||||||
|
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "pgp":
|
case "pgp":
|
||||||
// Get GPG key ID from flag or environment variable
|
// Get GPG key ID from flag, environment, or default key
|
||||||
var gpgKeyID string
|
var gpgKeyID string
|
||||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||||
gpgKeyID = flagKeyID
|
gpgKeyID = flagKeyID
|
||||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||||
gpgKeyID = envKeyID
|
gpgKeyID = envKeyID
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
|
// Try to get the default GPG key
|
||||||
|
defaultKeyID, err := getDefaultGPGKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||||
|
}
|
||||||
|
gpgKeyID = defaultKeyID
|
||||||
|
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this key is already added as an unlocker
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the GPG key ID to its fingerprint
|
||||||
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this GPG key is already added
|
||||||
|
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
|
||||||
|
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
|
||||||
|
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||||
@ -308,22 +496,63 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
|
||||||
|
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
|
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersRemove removes an unlocker
|
// UnlockersRemove removes an unlocker with safety checks
|
||||||
func (cli *Instance) UnlockersRemove(unlockerID string) error {
|
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return vlt.RemoveUnlocker(unlockerID)
|
// Get list of unlockers
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list unlockers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're removing the last unlocker
|
||||||
|
if len(unlockers) == 1 {
|
||||||
|
// Check if vault has secrets
|
||||||
|
numSecrets, err := vlt.NumSecrets()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to count secrets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if numSecrets > 0 && !force {
|
||||||
|
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
|
||||||
|
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
|
||||||
|
cmd.Println("If you want to proceed anyway, use --force")
|
||||||
|
|
||||||
|
return fmt.Errorf("refusing to remove last unlocker")
|
||||||
|
}
|
||||||
|
|
||||||
|
if numSecrets > 0 && force {
|
||||||
|
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the unlocker
|
||||||
|
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockerSelect selects an unlocker as current
|
// UnlockerSelect selects an unlocker as current
|
||||||
@ -336,3 +565,69 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|||||||
|
|
||||||
return vlt.SelectUnlocker(unlockerID)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkUnlockerExists checks if an unlocker with the given ID exists
|
||||||
|
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
|
||||||
|
// Get the list of unlockers and check if any match the ID
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return nil // If we can't list unlockers, assume it doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vault directory to construct unlocker instances
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each unlocker's ID
|
||||||
|
for _, metadata := range unlockers {
|
||||||
|
// Construct the unlocker based on type to get its ID
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
// Check if this matches our metadata
|
||||||
|
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
switch metadata.Type {
|
||||||
|
case "passphrase":
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
case "keychain":
|
||||||
|
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
case "pgp":
|
||||||
|
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker != nil && unlocker.GetID() == unlockerID {
|
||||||
|
return fmt.Errorf("unlocker already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -27,14 +28,16 @@ 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",
|
||||||
Short: "List available vaults",
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List available vaults",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
@ -63,10 +66,13 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newVaultSelectCmd() *cobra.Command {
|
func newVaultSelectCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "select <name>",
|
Use: "select <name>",
|
||||||
Short: "Select a vault as current",
|
Short: "Select a vault as current",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
@ -76,11 +82,14 @@ func newVaultSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newVaultImportCmd() *cobra.Command {
|
func newVaultImportCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "import <vault-name>",
|
Use: "import <vault-name>",
|
||||||
Short: "Import a mnemonic into a vault",
|
Short: "Import a mnemonic into a vault",
|
||||||
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
vaultName := "default"
|
vaultName := "default"
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@ -94,6 +103,29 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newVaultRemoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a vault",
|
||||||
|
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
||||||
|
`switch to another vault if removing the currently selected one.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
return cli.RemoveVault(cmd, args[0], force)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// ListVaults lists all available vaults
|
// ListVaults lists all available vaults
|
||||||
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
@ -147,12 +179,95 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
|
// Get or prompt for mnemonic
|
||||||
|
var mnemonicStr string
|
||||||
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
mnemonicStr = envMnemonic
|
||||||
|
} else {
|
||||||
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
// Read mnemonic securely without echo
|
||||||
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
|
}
|
||||||
|
|
||||||
|
if mnemonicStr == "" {
|
||||||
|
return fmt.Errorf("mnemonic cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the mnemonic
|
||||||
|
mnemonicWords := strings.Fields(mnemonicStr)
|
||||||
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
|
return fmt.Errorf("invalid BIP39 mnemonic phrase")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set mnemonic in environment for CreateVault to use
|
||||||
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
|
defer func() {
|
||||||
|
if originalMnemonic != "" {
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create the vault - it will handle key derivation internally
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the vault metadata to retrieve the derivation index
|
||||||
|
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
||||||
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the long-term key using the same index that CreateVault used
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the vault with the derived long-term key
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Get or prompt for passphrase
|
||||||
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
|
} else {
|
||||||
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
|
// Use secure passphrase input with confirmation
|
||||||
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
// Create passphrase-protected unlocker
|
||||||
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||||
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
|
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -295,3 +410,90 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveVault removes a vault with safety checks
|
||||||
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
|
// Get list of all vaults
|
||||||
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list vaults: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault exists
|
||||||
|
vaultExists := false
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v == name {
|
||||||
|
vaultExists = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !vaultExists {
|
||||||
|
return fmt.Errorf("vault '%s' does not exist", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the last vault
|
||||||
|
if len(vaults) == 1 {
|
||||||
|
return fmt.Errorf("cannot remove the last vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the current vault
|
||||||
|
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
isCurrentVault := currentVault.GetName() == name
|
||||||
|
|
||||||
|
// Load the vault to check for secrets
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault has secrets
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
hasSecrets := false
|
||||||
|
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
|
||||||
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
hasSecrets = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require --force if vault has secrets
|
||||||
|
if hasSecrets && !force {
|
||||||
|
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If removing current vault, switch to another vault first
|
||||||
|
if isCurrentVault {
|
||||||
|
// Find another vault to switch to
|
||||||
|
var newVault string
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v != name {
|
||||||
|
newVault = v
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the new vault
|
||||||
|
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
|
||||||
|
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||||
|
}
|
||||||
|
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the vault directory
|
||||||
|
if err := cli.fs.RemoveAll(vaultDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed vault '%s'\n", name)
|
||||||
|
if hasSecrets {
|
||||||
|
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -33,9 +33,11 @@ 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>",
|
||||||
Short: "List all versions of a secret",
|
Aliases: []string{"ls"},
|
||||||
Args: cobra.ExactArgs(1),
|
Short: "List all versions of a secret",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
@ -47,12 +49,40 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Short: "Promote a specific version to current",
|
Short: "Promote a specific version to current",
|
||||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Complete secret name for first arg
|
||||||
|
if len(args) == 0 {
|
||||||
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
// TODO: Complete version numbers for second arg
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.PromoteVersion(cmd, args[0], args[1])
|
return cli.PromoteVersion(cmd, args[0], args[1])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
versionCmd.AddCommand(listCmd, promoteCmd)
|
// Remove version command
|
||||||
|
removeCmd := &cobra.Command{
|
||||||
|
Use: "remove <secret-name> <version>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a specific version of a secret",
|
||||||
|
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
||||||
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Complete secret name for first arg
|
||||||
|
if len(args) == 0 {
|
||||||
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
// TODO: Complete version numbers for second arg
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return cli.RemoveVersion(cmd, args[0], args[1])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
||||||
|
|
||||||
return versionCmd
|
return versionCmd
|
||||||
}
|
}
|
||||||
@ -207,3 +237,60 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveVersion removes a specific version of a secret
|
||||||
|
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||||
|
// Get current vault
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the encoded secret name
|
||||||
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if version exists
|
||||||
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the current version
|
||||||
|
if version == currentVersion {
|
||||||
|
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the version directory
|
||||||
|
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -54,7 +54,7 @@ func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithIdentity decrypts data with an identity using age
|
// DecryptWithIdentity decrypts data with an identity using age
|
||||||
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
|
||||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||||
@ -65,40 +65,40 @@ func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
resultBuffer := memguard.NewBufferFromBytes(result)
|
||||||
|
|
||||||
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||||
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the passphrase string temporarily
|
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||||
passphraseStr := passphrase.String()
|
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||||
recipient, err := age.NewScryptRecipient(passphraseStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer for the data
|
return EncryptToRecipient(data, recipient)
|
||||||
dataBuffer := memguard.NewBufferFromBytes(data)
|
|
||||||
defer dataBuffer.Destroy()
|
|
||||||
|
|
||||||
return EncryptToRecipient(dataBuffer, recipient)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the passphrase string temporarily
|
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||||
passphraseStr := passphrase.String()
|
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||||
identity, err := age.NewScryptIdentity(passphraseStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -6,19 +9,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
@ -107,30 +113,22 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
||||||
slog.String("unlocker_id", k.GetID()),
|
slog.String("unlocker_id", k.GetID()),
|
||||||
slog.Int("decrypted_length", len(agePrivKeyData)),
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 6: Parse the decrypted age private key
|
// Step 6: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
||||||
|
|
||||||
// Create a secure buffer for the private key data
|
|
||||||
agePrivKeyBuffer := memguard.NewBufferFromBytes(agePrivKeyData)
|
|
||||||
defer agePrivKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original private key data
|
|
||||||
for i := range agePrivKeyData {
|
|
||||||
agePrivKeyData[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||||
@ -163,15 +161,18 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from keychain item name
|
// GetID implements Unlocker interface - generates ID from keychain item name
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
// Generate ID using keychain item name
|
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
// This matches the passphrase unlocker format
|
||||||
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The vault metadata is corrupt - this is a fatal error
|
hostname = "unknown"
|
||||||
// We cannot continue with a fallback ID as that would mask data corruption
|
|
||||||
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-keychain", keychainItemName)
|
// Use the creation timestamp from metadata
|
||||||
|
createdAt := k.Metadata.CreatedAt
|
||||||
|
timestamp := createdAt.Format("2006-01-02.15.04")
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||||
@ -301,13 +302,13 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt long-term private key using current unlocker
|
// Decrypt long-term private key using current unlocker
|
||||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the decrypted key in a secure buffer
|
// Return the decrypted key buffer
|
||||||
return memguard.NewBufferFromBytes(ltPrivKeyData), nil
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||||
@ -368,7 +369,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
}
|
}
|
||||||
@ -409,8 +410,12 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a secure buffer for keychain data
|
||||||
|
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
||||||
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 8: Store data in keychain
|
// Step 8: Store data in keychain
|
||||||
if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil {
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
||||||
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,11 +447,10 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMacOSAvailable verifies that we're running on macOS and security command is available
|
// checkMacOSAvailable verifies that we're running on macOS
|
||||||
func checkMacOSAvailable() error {
|
func checkMacOSAvailable() error {
|
||||||
cmd := exec.Command("/usr/bin/security", "help")
|
if runtime.GOOS != "darwin" {
|
||||||
if err := cmd.Run(); err != nil {
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||||
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -465,59 +469,78 @@ func validateKeychainItemName(itemName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeInKeychain stores data in the macOS keychain using the security command
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||||
func storeInKeychain(itemName string, data []byte) error {
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec
|
|
||||||
"-a", itemName,
|
|
||||||
"-s", itemName,
|
|
||||||
"-w", string(data),
|
|
||||||
"-U") // Update if exists
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
|
item.SetDescription("Secret vault keychain data")
|
||||||
|
item.SetData([]byte(data.String()))
|
||||||
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||||
|
|
||||||
|
// First try to delete any existing item
|
||||||
|
deleteItem := keychain.NewItem()
|
||||||
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
deleteItem.SetAccount(itemName)
|
||||||
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
|
// Add the new item
|
||||||
|
if err := keychain.AddItem(item); err != nil {
|
||||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
|
query := keychain.NewItem()
|
||||||
"-a", itemName,
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
"-s", itemName,
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
"-w") // Return password only
|
query.SetAccount(itemName)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
results, err := keychain.QueryItem(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing newline if present
|
if len(results) == 0 {
|
||||||
if len(output) > 0 && output[len(output)-1] == '\n' {
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||||
output = output[:len(output)-1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
return results[0].Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteFromKeychain removes an item from the macOS keychain using the security command
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||||
func deleteFromKeychain(itemName string) error {
|
func deleteFromKeychain(itemName string) error {
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
|
item := keychain.NewItem()
|
||||||
"-a", itemName,
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
"-s", itemName)
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
73
internal/secret/keychainunlocker_stub.go
Normal file
73
internal/secret/keychainunlocker_stub.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||||
|
type KeychainUnlockerMetadata struct {
|
||||||
|
UnlockerMetadata
|
||||||
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeychainUnlocker is a stub for non-Darwin platforms
|
||||||
|
type KeychainUnlocker struct {
|
||||||
|
Directory string
|
||||||
|
Metadata UnlockerMetadata
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentity panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetType() string {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirectory panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetDirectory() string {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the unlocker ID
|
||||||
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeychainItemName panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove panics on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) Remove() error {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeychainUnlocker panics on non-Darwin platforms
|
||||||
|
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeychainUnlocker panics on non-Darwin platforms
|
||||||
|
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLongTermPrivateKey panics on non-Darwin platforms
|
||||||
|
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
|
panic("keychain unlockers are only supported on macOS")
|
||||||
|
}
|
167
internal/secret/keychainunlocker_test.go
Normal file
167
internal/secret/keychainunlocker_test.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
testItemName := "test-secret-keychain-item"
|
||||||
|
testData := "test-secret-data-12345"
|
||||||
|
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
|
// Clean up any existing item first
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
|
||||||
|
// Test 1: Store data in keychain
|
||||||
|
err := storeInKeychain(testItemName, testBuffer)
|
||||||
|
require.NoError(t, err, "Failed to store data in keychain")
|
||||||
|
|
||||||
|
// Test 2: Retrieve data from keychain
|
||||||
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||||
|
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
|
||||||
|
|
||||||
|
// Test 3: Update existing item (store again with different data)
|
||||||
|
newTestData := "updated-test-data-67890"
|
||||||
|
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
|
||||||
|
defer newTestBuffer.Destroy()
|
||||||
|
|
||||||
|
err = storeInKeychain(testItemName, newTestBuffer)
|
||||||
|
require.NoError(t, err, "Failed to update data in keychain")
|
||||||
|
|
||||||
|
// Verify updated data
|
||||||
|
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||||
|
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
|
||||||
|
|
||||||
|
// Test 4: Delete from keychain
|
||||||
|
err = deleteFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to delete data from keychain")
|
||||||
|
|
||||||
|
// Test 5: Verify item is deleted (should fail to retrieve)
|
||||||
|
_, err = retrieveFromKeychain(testItemName)
|
||||||
|
assert.Error(t, err, "Expected error when retrieving deleted item")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeychainInvalidItemName(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
testData := memguard.NewBufferFromBytes([]byte("test"))
|
||||||
|
defer testData.Destroy()
|
||||||
|
|
||||||
|
// Test invalid item names
|
||||||
|
invalidNames := []string{
|
||||||
|
"", // Empty name
|
||||||
|
"test space", // Contains space
|
||||||
|
"test/slash", // Contains slash
|
||||||
|
"test\\backslash", // Contains backslash
|
||||||
|
"test:colon", // Contains colon
|
||||||
|
"test;semicolon", // Contains semicolon
|
||||||
|
"test|pipe", // Contains pipe
|
||||||
|
"test@at", // Contains @
|
||||||
|
"test#hash", // Contains #
|
||||||
|
"test$dollar", // Contains $
|
||||||
|
"test&ersand", // 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)
|
||||||
|
}
|
@ -76,10 +76,11 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Test encrypting private key with passphrase
|
// Test encrypting private key with passphrase
|
||||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||||
privKeyData := []byte(agePrivateKey)
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphraseBuffer)
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -84,30 +84,22 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
// Decrypt the unlocker private key with passphrase
|
// Decrypt the unlocker private key with passphrase
|
||||||
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted unlocker private key",
|
DebugWith("Successfully decrypted unlocker private key",
|
||||||
slog.String("unlocker_id", p.GetID()),
|
slog.String("unlocker_id", p.GetID()),
|
||||||
slog.Int("decrypted_length", len(privKeyData)),
|
slog.Int("decrypted_length", privKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse the decrypted private key
|
// Parse the decrypted private key
|
||||||
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
// Create a secure buffer for the private key data
|
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes(privKeyData)
|
|
||||||
defer privKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original private key data
|
|
||||||
for i := range privKeyData {
|
|
||||||
privKeyData[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||||
|
@ -45,7 +45,10 @@ pinentry-mode loopback
|
|||||||
origDecryptFunc := secret.GPGDecryptFunc
|
origDecryptFunc := secret.GPGDecryptFunc
|
||||||
|
|
||||||
// Set custom GPG functions for this test
|
// Set custom GPG functions for this test
|
||||||
secret.GPGEncryptFunc = func(data []byte, keyID string) ([]byte, error) {
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
cmd := exec.Command("gpg",
|
cmd := exec.Command("gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
@ -60,7 +63,7 @@ pinentry-mode loopback
|
|||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(data)
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
@ -69,7 +72,7 @@ pinentry-mode loopback
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.GPGDecryptFunc = func(encryptedData []byte) ([]byte, error) {
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.Command("gpg",
|
cmd := exec.Command("gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
@ -88,7 +91,8 @@ pinentry-mode loopback
|
|||||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout.Bytes(), nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original functions after test
|
// Restore original functions after test
|
||||||
@ -444,8 +448,9 @@ Passphrase: ` + testPassphrase + `
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GPG encrypt the private key using our custom encrypt function
|
// GPG encrypt the private key using our custom encrypt function
|
||||||
privKeyData := []byte(ageIdentity.String())
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyData, keyID)
|
defer privKeyBuffer.Destroy()
|
||||||
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -20,11 +20,13 @@ import (
|
|||||||
var (
|
var (
|
||||||
// GPGEncryptFunc is the function used for GPG encryption
|
// GPGEncryptFunc is the function used for GPG encryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
GPGEncryptFunc = gpgEncryptDefault //nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
|
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
|
||||||
|
|
||||||
// GPGDecryptFunc is the function used for GPG decryption
|
// GPGDecryptFunc is the function used for GPG decryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
|
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||||
|
|
||||||
// gpgKeyIDRegex validates GPG key IDs
|
// gpgKeyIDRegex validates GPG key IDs
|
||||||
// Allows either:
|
// Allows either:
|
||||||
@ -79,21 +81,22 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Step 2: Decrypt the age private key using GPG
|
// Step 2: Decrypt the age private key using GPG
|
||||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||||
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
||||||
}
|
}
|
||||||
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted age private key with GPG",
|
DebugWith("Successfully decrypted age private key with GPG",
|
||||||
slog.String("unlocker_id", p.GetID()),
|
slog.String("unlocker_id", p.GetID()),
|
||||||
slog.Int("decrypted_length", len(agePrivKeyData)),
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 3: Parse the decrypted age private key
|
// Step 3: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||||
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
@ -125,7 +128,7 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID
|
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: <keyid>-pgp
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The vault metadata is corrupt - this is a fatal error
|
// The vault metadata is corrupt - this is a fatal error
|
||||||
@ -133,7 +136,7 @@ func (p *PGPUnlocker) GetID() string {
|
|||||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp", gpgKeyID)
|
return fmt.Sprintf("pgp-%s", gpgKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
@ -253,7 +256,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
||||||
}
|
}
|
||||||
@ -264,7 +267,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||||
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
|
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
@ -310,8 +313,8 @@ func validateGPGKeyID(keyID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||||
func resolveGPGKeyFingerprint(keyID string) (string, error) {
|
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||||
if err := validateGPGKeyID(keyID); err != nil {
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
@ -348,13 +351,16 @@ func checkGPGAvailable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||||
func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if err := validateGPGKeyID(keyID); err != nil {
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
||||||
cmd.Stdin = strings.NewReader(string(data))
|
cmd.Stdin = strings.NewReader(data.String())
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -365,7 +371,7 @@ func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||||
func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||||
|
|
||||||
@ -374,5 +380,8 @@ func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
outputBuffer := memguard.NewBufferFromBytes(output)
|
||||||
|
|
||||||
|
return outputBuffer, nil
|
||||||
}
|
}
|
||||||
|
@ -62,35 +62,8 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save is deprecated - use vault.AddSecret directly which creates versions
|
|
||||||
// Kept for backward compatibility
|
|
||||||
func (s *Secret) Save(value []byte, force bool) error {
|
|
||||||
DebugWith("Saving secret (deprecated method)",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
|
||||||
slog.Int("value_length", len(value)),
|
|
||||||
slog.Bool("force", force),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create a secure buffer for the value - note that the caller
|
|
||||||
// should ideally pass a LockedBuffer directly to vault.AddSecret
|
|
||||||
valueBuffer := memguard.NewBufferFromBytes(value)
|
|
||||||
defer valueBuffer.Destroy()
|
|
||||||
|
|
||||||
err := s.vault.AddSecret(s.Name, valueBuffer, force)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Successfully saved secret", "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||||
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||||
DebugWith("Getting secret value",
|
DebugWith("Getting secret value",
|
||||||
slog.String("secret_name", s.Name),
|
slog.String("secret_name", s.Name),
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
slog.String("vault_name", s.vault.GetName()),
|
||||||
@ -206,16 +179,17 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
|
|
||||||
// Decrypt the encrypted long-term private key using the unlocker
|
// Decrypt the encrypted long-term private key using the unlocker
|
||||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Parse the long-term private key
|
// Parse the long-term private key
|
||||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
@ -277,15 +277,16 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Decrypt version private key using long-term key
|
// Step 2: Decrypt version private key using long-term key
|
||||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Step 3: Parse version private key
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@ -302,16 +303,17 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Decrypt metadata using version key
|
// Step 5: Decrypt metadata using version key
|
||||||
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 6: Unmarshal metadata
|
// Step 6: Unmarshal metadata
|
||||||
var metadata VersionMetadata
|
var metadata VersionMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
|
||||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||||
@ -324,7 +326,7 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the version value
|
// GetValue retrieves and decrypts the version value
|
||||||
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
|
||||||
DebugWith("Getting version value",
|
DebugWith("Getting version value",
|
||||||
slog.String("secret_name", sv.SecretName),
|
slog.String("secret_name", sv.SecretName),
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
@ -352,16 +354,17 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
// Step 2: Decrypt version private key using long-term key
|
// Step 2: Decrypt version private key using long-term key
|
||||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||||
|
|
||||||
// Step 3: Parse version private key
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@ -381,7 +384,7 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
// Step 5: Decrypt value using version key
|
// Step 5: Decrypt value using version key
|
||||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||||
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@ -390,10 +393,10 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
Debug("Successfully retrieved version value",
|
Debug("Successfully retrieved version value",
|
||||||
"version", sv.Version,
|
"version", sv.Version,
|
||||||
"value_length", len(value),
|
"value_length", valueBuffer.Size(),
|
||||||
"is_empty", len(value) == 0)
|
"is_empty", valueBuffer.Size() == 0)
|
||||||
|
|
||||||
return value, nil
|
return valueBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVersions lists all versions of a secret
|
// ListVersions lists all versions of a secret
|
||||||
|
@ -255,10 +255,11 @@ func TestSecretVersionGetValue(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Retrieve the value
|
// Retrieve the value
|
||||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer retrievedBuffer.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedValue)
|
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersions(t *testing.T) {
|
func TestListVersions(t *testing.T) {
|
||||||
|
@ -204,6 +204,12 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
if err := newVersion.Save(value); err != nil {
|
if err := newVersion.Save(value); err != nil {
|
||||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||||
|
|
||||||
|
// Clean up the secret directory if this was a new secret
|
||||||
|
if !exists {
|
||||||
|
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
|
||||||
|
_ = v.fs.RemoveAll(secretDir)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to save version: %w", err)
|
return fmt.Errorf("failed to save version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,13 +265,14 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt version private key using long-term key
|
// Decrypt version private key using long-term key
|
||||||
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Parse version private key
|
// Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||||
}
|
}
|
||||||
@ -393,21 +400,26 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a copy to return since the buffer will be destroyed
|
||||||
|
result := make([]byte, decryptedValue.Size())
|
||||||
|
copy(result, decryptedValue.Bytes())
|
||||||
|
decryptedValue.Destroy()
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted secret version",
|
secret.DebugWith("Successfully decrypted secret version",
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.Int("decrypted_length", len(decryptedValue)),
|
slog.Int("decrypted_length", len(result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
||||||
secret.Debug("Vault secret decryption debug info",
|
secret.Debug("Vault secret decryption debug info",
|
||||||
"secret_name", name,
|
"secret_name", name,
|
||||||
"version", version,
|
"version", version,
|
||||||
"decrypted_value_length", len(decryptedValue),
|
"decrypted_value_length", len(result),
|
||||||
"is_empty", len(decryptedValue) == 0)
|
"is_empty", len(result) == 0)
|
||||||
|
|
||||||
return decryptedValue, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockVault unlocks the vault and returns the long-term private key
|
// UnlockVault unlocks the vault and returns the long-term private key
|
||||||
|
@ -346,7 +346,9 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
|
|
||||||
// Encrypt private key with passphrase
|
// Encrypt private key with passphrase
|
||||||
privKeyStr := unlockerIdentity.String()
|
privKeyStr := unlockerIdentity.String()
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase)
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -157,22 +157,23 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Decrypt long-term private key using unlocker
|
// Decrypt long-term private key using unlocker
|
||||||
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
||||||
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
|
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted long-term private key",
|
secret.DebugWith("Successfully decrypted long-term private key",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("unlocker_type", unlocker.GetType()),
|
slog.String("unlocker_type", unlocker.GetType()),
|
||||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse long-term private key
|
// Parse long-term private key
|
||||||
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
||||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
@ -207,3 +208,48 @@ func (v *Vault) GetName() string {
|
|||||||
func (v *Vault) GetFilesystem() afero.Fs {
|
func (v *Vault) GetFilesystem() afero.Fs {
|
||||||
return v.fs
|
return v.fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NumSecrets returns the number of secrets in the vault
|
||||||
|
func (v *Vault) NumSecrets() (int, error) {
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||||
|
if !exists {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count only directories that contain at least one version file
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this secret directory contains any version files
|
||||||
|
secretDir := filepath.Join(secretsDir, entry.Name())
|
||||||
|
versionFiles, err := afero.ReadDir(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip directories we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for at least one version file (excluding "current" symlink)
|
||||||
|
for _, vFile := range versionFiles {
|
||||||
|
if !vFile.IsDir() && vFile.Name() != "current" {
|
||||||
|
count++
|
||||||
|
|
||||||
|
break // Found at least one version, count this secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
87
internal/vault/vault_error_test.go
Normal file
87
internal/vault/vault_error_test.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package vault_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
||||||
|
// Create in-memory filesystem
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Create a vault directory without a public key (simulating the error condition)
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", "broken")
|
||||||
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
|
// Create currentvault symlink
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
|
|
||||||
|
// Create vault instance
|
||||||
|
vlt := vault.NewVault(fs, stateDir, "broken")
|
||||||
|
|
||||||
|
// Try to add a secret - this should fail
|
||||||
|
secretName := "test-secret"
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
err := vlt.AddSecret(secretName, value, false)
|
||||||
|
require.Error(t, err, "AddSecret should fail when public key is missing")
|
||||||
|
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
||||||
|
|
||||||
|
// Verify that the secret directory was NOT created
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||||
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
|
||||||
|
// Verify the secrets.d directory is empty or doesn't exist
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
if exists, _ := afero.DirExists(fs, secretsDir); exists {
|
||||||
|
entries, err := afero.ReadDir(fs, secretsDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries, "secrets.d directory should be empty after failed AddSecret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||||
|
// Create in-memory filesystem
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Create a vault directory with public key
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", "test")
|
||||||
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
|
// Create a mock public key that will cause encryption to fail
|
||||||
|
// by using an invalid age public key format
|
||||||
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"), secret.FilePerms))
|
||||||
|
|
||||||
|
// Create currentvault symlink
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
|
|
||||||
|
// Create vault instance
|
||||||
|
vlt := vault.NewVault(fs, stateDir, "test")
|
||||||
|
|
||||||
|
// Try to add a secret - this should fail during encryption
|
||||||
|
secretName := "test-secret"
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
err := vlt.AddSecret(secretName, value, false)
|
||||||
|
require.Error(t, err, "AddSecret should fail with invalid public key")
|
||||||
|
|
||||||
|
// Verify that the secret directory was NOT created
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||||
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user