Compare commits
102 Commits
dd2e95f8af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3d3fb3b69 | |||
| 4dc26c9394 | |||
|
|
7546cb094f | ||
| 797d2678c8 | |||
|
|
78015afb35 | ||
| 1c330c697f | |||
| d18e286377 | |||
| f49fde3a06 | |||
| 206651f89a | |||
|
|
c0f221b1ca | ||
| 09be20a044 | |||
| 2e1ba7d2e0 | |||
| 1a23016df1 | |||
| ebe3c17618 | |||
|
|
1a96360f6a | ||
| 4f5d2126d6 | |||
|
|
6be4601763 | ||
|
|
36ece2fca7 | ||
|
|
dc225bd0b1 | ||
|
|
6acd57d0ec | ||
|
|
596027f210 | ||
|
|
0aa9a52497 | ||
|
|
09ec79c57e | ||
|
|
e8339f4d12 | ||
|
|
4f984cd9c6 | ||
|
|
d1caf0a208 | ||
|
|
8eb25b98fd | ||
|
|
6211b8e768 | ||
|
|
0307f23024 | ||
|
|
3fd30bb9e6 | ||
| 6ff00c696a | |||
| c6551e4901 | |||
| b06d7fa3f4 | |||
| 16d5b237d2 | |||
| 660de5716a | |||
| 51fb2805fd | |||
| 6ffb24b544 | |||
|
|
4419ef7730 | ||
|
|
991b1a5a0b | ||
|
|
fd77a047f9 | ||
|
|
341428d9ca | ||
| 128c53a11d | |||
| 7264026d66 | |||
| 20690ba652 | |||
| 949a5aee61 | |||
| 18fb79e971 | |||
| b301a414cb | |||
| 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 | |||
| 7596049828 | |||
| d3ca006886 | |||
| f91281e991 | |||
| 7c5e78db17 | |||
| 8e374b3d24 | |||
| c9774e89e0 | |||
| f9938135c6 | |||
| 386a27c0b6 | |||
| 080a3dc253 | |||
| 811ddee3b7 | |||
| 4e242c3491 | |||
| 54fce0f187 | |||
| 93a32217e0 | |||
| 95ba80f618 | |||
| d710323bd0 | |||
| 38b450cbcf | |||
| 6fe49344e2 | |||
| 6e01ae6002 | |||
| 11e43542cf | |||
| 2256a37b72 | |||
| 533133486c | |||
| eb19fa4b97 | |||
| 5ed850196b | |||
| be1f323a09 | |||
| bdcddadf90 | |||
| 4062242063 | |||
| abcc7b6c3a | |||
| 9e35bf21a3 | |||
| 2a1e0337fd | |||
| dcc15008cd |
@@ -1,22 +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:*)"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
EXTREMELY IMPORTANT: Read and follow the policies, procedures, and
|
|
||||||
instructions in the `AGENTS.md` file in the root of the repository. Make
|
|
||||||
sure you follow *all* of the instructions meticulously.
|
|
||||||
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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,4 +3,10 @@
|
|||||||
/secret
|
/secret
|
||||||
*.log
|
*.log
|
||||||
cli.test
|
cli.test
|
||||||
|
vault.test
|
||||||
|
*.test
|
||||||
|
settings.local.json
|
||||||
|
|
||||||
|
# Stale files
|
||||||
|
.cursorrules
|
||||||
|
coverage.out
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
go: "1.24"
|
||||||
go: "1.22"
|
tests: false
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
@@ -14,7 +16,6 @@ linters:
|
|||||||
- mnd # An analyzer to detect magic numbers
|
- mnd # An analyzer to detect magic numbers
|
||||||
- lll # Reports long lines
|
- lll # Reports long lines
|
||||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||||
- gofumpt # Gofumpt checks whether code was gofumpt-ed
|
|
||||||
- gochecknoglobals # Check that no global variables exist
|
- gochecknoglobals # Check that no global variables exist
|
||||||
|
|
||||||
# Default/existing linters that are commonly useful
|
# Default/existing linters that are commonly useful
|
||||||
@@ -22,11 +23,7 @@ linters:
|
|||||||
- errcheck
|
- errcheck
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
- gosimple
|
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- typecheck
|
|
||||||
- gofmt
|
|
||||||
- goimports
|
|
||||||
- misspell
|
- misspell
|
||||||
- revive
|
- revive
|
||||||
- gosec
|
- gosec
|
||||||
@@ -67,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:
|
||||||
@@ -78,19 +83,12 @@ linters-settings:
|
|||||||
testifylint:
|
testifylint:
|
||||||
enable-all: true
|
enable-all: true
|
||||||
|
|
||||||
usetesting:
|
usetesting: {}
|
||||||
strict: true
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
exclude-rules:
|
exclude-rules:
|
||||||
# Exclude some linters from running on tests files
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
- mnd
|
|
||||||
- unparam
|
|
||||||
|
|
||||||
# Allow long lines in generated code or test data
|
|
||||||
- path: ".*_gen\\.go"
|
- path: ".*_gen\\.go"
|
||||||
linters:
|
linters:
|
||||||
- lll
|
- lll
|
||||||
@@ -100,5 +98,31 @@ issues:
|
|||||||
linters:
|
linters:
|
||||||
- revive
|
- revive
|
||||||
|
|
||||||
max-issues-per-linter: 0
|
# Allow ALL_CAPS constant names
|
||||||
max-same-issues: 0
|
- 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
|
||||||
|
|||||||
14
AGENTS.md
14
AGENTS.md
@@ -141,3 +141,17 @@ Version: 2025-06-08
|
|||||||
- Local application imports
|
- Local application imports
|
||||||
|
|
||||||
Each group should be separated by a blank line.
|
Each group should be separated by a blank line.
|
||||||
|
|
||||||
|
## Go-Specific Guidelines
|
||||||
|
|
||||||
|
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
|
||||||
|
|
||||||
|
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
|
||||||
|
|
||||||
|
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
||||||
|
|
||||||
|
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
|
||||||
|
|
||||||
|
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
|
||||||
|
|
||||||
|
6. **Pin external dependencies by commit hash**, not mutable tags.
|
||||||
|
|||||||
87
CLAUDE.md
87
CLAUDE.md
@@ -1,8 +1,4 @@
|
|||||||
# Rules
|
# IMPORTANT RULES
|
||||||
|
|
||||||
Read the rules in AGENTS.md and follow them.
|
|
||||||
|
|
||||||
# Memory
|
|
||||||
|
|
||||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||||
commit messages (which it erroneously refers to as "attribution") is not
|
commit messages (which it erroneously refers to as "attribution") is not
|
||||||
@@ -16,9 +12,84 @@ Read the rules in AGENTS.md and follow them.
|
|||||||
* Code should always be formatted before committing. Do not commit
|
* Code should always be formatted before committing. Do not commit
|
||||||
unformatted code.
|
unformatted code.
|
||||||
|
|
||||||
* Code should always be linted before committing. Do not commit
|
* Code should always be linted and linter errors fixed before committing.
|
||||||
unlinted code.
|
NEVER commit code that does not pass the linter. DO NOT modify the linter
|
||||||
|
config unless specifically instructed.
|
||||||
|
|
||||||
* The test suite is fast and local. When running tests, don't run
|
* The test suite is fast and local. When running tests, NEVER run
|
||||||
individual parts of the test suite, always run the whole thing by running
|
individual parts of the test suite, always run the whole thing by running
|
||||||
"make test".
|
"make test".
|
||||||
|
|
||||||
|
* Do not stop working on a task until you have reached the definition of
|
||||||
|
done provided to you in the initial instruction. Don't do part or most of
|
||||||
|
the work, do all of the work until the criteria for done are met.
|
||||||
|
|
||||||
|
* When you complete each task, if the tests are passing and the code is
|
||||||
|
formatted and there are no linter errors, always commit and push your
|
||||||
|
work. Use a good commit message and don't mention any author or co-author
|
||||||
|
attribution.
|
||||||
|
|
||||||
|
* Do not create additional files in the root directory of the project
|
||||||
|
without asking permission first. Configuration files, documentation, and
|
||||||
|
build files are acceptable in the root, but source code and other files
|
||||||
|
should be organized in appropriate subdirectories.
|
||||||
|
|
||||||
|
* Do not use bare strings or numbers in code, especially if they appear
|
||||||
|
anywhere more than once. Always define a constant (usually at the top of
|
||||||
|
the file) and give it a descriptive name, then use that constant in the
|
||||||
|
code instead of the bare string or number.
|
||||||
|
|
||||||
|
* If you are fixing a bug, write a test first that reproduces the bug and
|
||||||
|
fails, and then fix the bug in the code, using the test to verify that the
|
||||||
|
fix worked.
|
||||||
|
|
||||||
|
* When implementing new features, be aware of potential side-effects (such
|
||||||
|
as state files on disk, data in the database, etc.) and ensure that it is
|
||||||
|
possible to mock or stub these side-effects in tests when designing an
|
||||||
|
API.
|
||||||
|
|
||||||
|
* When dealing with dates and times or timestamps, always use, display, and
|
||||||
|
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||||
|
to see the time in a different timezone, store the user's timezone in a
|
||||||
|
separate field and convert the UTC time to the user's timezone when
|
||||||
|
displaying it. For internal use and internal applications and
|
||||||
|
administrative purposes, always display UTC.
|
||||||
|
|
||||||
|
* When implementing programs, put the main.go in
|
||||||
|
./cmd/<program_name>/main.go and put the program's code in
|
||||||
|
./internal/<program_name>/. This allows for multiple programs to be
|
||||||
|
implemented in the same repository without cluttering the root directory.
|
||||||
|
main.go should simply import and call <program_name>.CLIEntry(). The
|
||||||
|
full implementation should be in ./internal/<program_name>/.
|
||||||
|
|
||||||
|
* When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
|
tests, or change the tests specifically to make them pass (unless there
|
||||||
|
is a bug in the test). This is cheating, and it is bad. You should only
|
||||||
|
be modifying the test if it is incorrect or if the test is no longer
|
||||||
|
relevant. In almost all cases, you should be fixing the code that is
|
||||||
|
being tested, or updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
|
* Always write a `Makefile` with the default target being `test`, and with a
|
||||||
|
`fmt` target that formats the code. The `test` target should run all
|
||||||
|
tests in the project, and the `fmt` target should format the code. `test`
|
||||||
|
should also have a prerequisite target `lint` that should run any linters
|
||||||
|
that are configured for the project.
|
||||||
|
|
||||||
|
* After each completed bugfix or feature, the code must be committed. Do
|
||||||
|
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||||
|
course. After each commit, push to the remote.
|
||||||
|
|
||||||
|
* Always write tests, even if they are extremely simple and just check for
|
||||||
|
correct syntax (ability to compile/import). If you are writing a new
|
||||||
|
feature, write a test for it. You don't need to target complete coverage,
|
||||||
|
but you should at least test any new functionality you add.
|
||||||
|
|
||||||
|
* Always use structured logging. Log any relevant state/context with the
|
||||||
|
messages (but do not log secrets). If stdout is not a terminal, output
|
||||||
|
the structured logs in jsonl format. Use go's log/slog.
|
||||||
|
|
||||||
|
* You do not need to summarize your changes in the chat after making them.
|
||||||
|
Making the changes and committing them is sufficient. If anything out of
|
||||||
|
the ordinary happened, please explain it, but in the normal case where you
|
||||||
|
found and fixed the bug, or implemented the feature, there is no need for
|
||||||
|
the end-of-change summary.
|
||||||
|
|||||||
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
|
||||||
|
|||||||
211
README.md
211
README.md
@@ -1,28 +1,40 @@
|
|||||||
# Secret - Hierarchical Secret Manager
|
# secret - Local 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 local 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.
|
||||||
|
|
||||||
|
It could be used as password manager, but was not designed as such. I
|
||||||
|
created it to scratch an itch for a secure key/value store for replacing a
|
||||||
|
bunch of pgp-encrypted files in a directory structure.
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
### Three-Layer Key Hierarchy
|
### Three-Layer Key Hierarchy
|
||||||
|
|
||||||
Secret implements a 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
|
||||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
|
the foundation for all encryption
|
||||||
3. **Version-specific Keys**: Per-version keys that encrypt individual secret values
|
2. **Unlockers**: Short-term keys that encrypt the long-term keys,
|
||||||
|
supporting multiple authentication methods
|
||||||
|
3. **Version-specific Keys**: Per-version keys that encrypt individual
|
||||||
|
secret values
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
Each secret maintains a history of versions, with each version having:
|
Each secret maintains a history of versions, with each version having:
|
||||||
- Its own encryption key pair
|
- Its own encryption key pair
|
||||||
- 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
|
||||||
|
|
||||||
### Vault System
|
### Vault System
|
||||||
|
|
||||||
Vaults provide logical separation of secrets, each with its own long-term key and unlocker set. This allows for complete isolation between different contexts (work, personal, projects).
|
Vaults provide logical separation of secrets, each with its own long-term
|
||||||
|
key and unlocker set. This allows for complete isolation between different
|
||||||
|
contexts (work, personal, projects).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -61,7 +73,9 @@ make build
|
|||||||
### Initialization
|
### Initialization
|
||||||
|
|
||||||
#### `secret init`
|
#### `secret init`
|
||||||
Initializes the secret manager with a default vault. Prompts for a BIP39 mnemonic phrase and creates the initial directory structure.
|
|
||||||
|
Initializes the secret manager with a default vault. Prompts for a BIP39
|
||||||
|
mnemonic phrase and creates the initial directory structure.
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
||||||
@@ -69,18 +83,33 @@ 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.
|
||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|
||||||
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
|
||||||
|
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`,
|
||||||
|
this command does not ask for confirmation.
|
||||||
|
|
||||||
|
Requires --force if the vault contains secrets. With --force, will
|
||||||
|
automatically switch to another vault if removing the current one.
|
||||||
|
|
||||||
|
- `--force, -f`: Force removal even if vault contains secrets
|
||||||
|
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
|
|
||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
|
|
||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
|
|
||||||
@@ -89,26 +118,53 @@ Adds a secret to the current vault. Reads the secret value from stdin.
|
|||||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||||
|
|
||||||
#### `secret get <secret-name> [--version <version>]`
|
#### `secret get <secret-name> [--version <version>]`
|
||||||
|
|
||||||
Retrieves and outputs a secret value to stdout.
|
Retrieves and outputs a secret value to stdout.
|
||||||
- `--version, -v`: Get a specific version (default: current)
|
- `--version, -v`: Get a specific version (default: current)
|
||||||
|
|
||||||
#### `secret list [filter] [--json]` / `secret ls`
|
#### `secret list [filter] [--json]` / `secret ls`
|
||||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
|
||||||
|
Lists all secrets in the current vault. Optional filter for substring
|
||||||
|
matching.
|
||||||
|
|
||||||
|
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
|
||||||
|
|
||||||
|
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
||||||
|
|
||||||
|
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||||
|
|
||||||
|
Moves or renames a secret within the current vault.
|
||||||
|
- Fails if the destination already exists
|
||||||
|
- Preserves all versions and metadata
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
#### `secret version list <secret-name>`
|
#### `secret version 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`
|
||||||
|
|
||||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||||
|
|
||||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||||
|
|
||||||
Generates and stores a random secret.
|
Generates and stores a random secret.
|
||||||
- `--length, -l`: Length of generated secret (default: 16)
|
- `--length, -l`: Length of generated secret (default: 16)
|
||||||
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
||||||
@@ -116,39 +172,56 @@ 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)
|
||||||
|
- `secure-enclave`: Hardware-backed Secure Enclave protection (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.
|
||||||
|
|
||||||
### Import Operations
|
### Import Operations
|
||||||
|
|
||||||
#### `secret import <secret-name> --source <filename>`
|
#### `secret import <secret-name> --source <filename>`
|
||||||
|
|
||||||
Imports a secret from a file and stores it in the current vault under the given name.
|
Imports a secret from a file and stores it in the current vault under the given name.
|
||||||
|
|
||||||
#### `secret vault import [vault-name]`
|
#### `secret vault import [vault-name]`
|
||||||
|
|
||||||
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||||
|
|
||||||
### Encryption Operations
|
### Encryption Operations
|
||||||
|
|
||||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
|
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
|
||||||
|
|
||||||
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
Decrypts data using an Age key stored as a secret.
|
Decrypts data using an Age key stored as a secret.
|
||||||
|
|
||||||
## Storage Architecture
|
## Storage Architecture
|
||||||
@@ -169,7 +242,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
|
||||||
@@ -189,12 +262,13 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
|
|
||||||
### Key Management and Encryption Flow
|
### Key Management and Encryption Flow
|
||||||
|
|
||||||
#### Long-term Keys
|
#### 1: Long-term Keys
|
||||||
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
|
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
|
||||||
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
||||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
|
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
|
||||||
|
|
||||||
#### Unlockers
|
#### 2: Unlockers
|
||||||
|
|
||||||
Unlockers provide different authentication methods to access the long-term keys:
|
Unlockers provide different authentication methods to access the long-term keys:
|
||||||
|
|
||||||
1. **Passphrase Unlockers**:
|
1. **Passphrase Unlockers**:
|
||||||
@@ -207,10 +281,23 @@ 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):
|
||||||
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
|
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
|
||||||
|
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
||||||
|
- Protected by biometric authentication (Touch ID) or system password
|
||||||
|
|
||||||
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
|
#### 3: Secret-specific Keys
|
||||||
- Each secret has its own encryption key pair
|
|
||||||
|
- Each secret version has its own encryption key pair
|
||||||
- Private key encrypted to the vault's long-term key
|
- Private key encrypted to the vault's long-term key
|
||||||
- Provides forward secrecy and granular access control
|
- Provides forward secrecy and granular access control
|
||||||
|
|
||||||
@@ -224,27 +311,32 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
- Uses the [Age encryption library](https://age-encryption.org/) with X25519 keys
|
|
||||||
|
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
|
||||||
- All private keys are encrypted at rest
|
- All private keys are encrypted at rest
|
||||||
- No plaintext secrets stored on disk
|
- No plaintext secrets stored on disk
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
|
|
||||||
- Multiple authentication methods supported
|
- Multiple authentication methods supported
|
||||||
- Hierarchical key architecture provides defense in depth
|
|
||||||
- Vault isolation prevents cross-contamination
|
- Vault isolation prevents cross-contamination
|
||||||
|
|
||||||
### Forward Secrecy
|
### Forward Secrecy
|
||||||
|
|
||||||
- Per-version encryption keys limit exposure if compromised
|
- Per-version encryption keys limit exposure if compromised
|
||||||
- Each version is independently encrypted
|
- Each version is independently encrypted
|
||||||
- Long-term keys protected by multiple unlocker layers
|
|
||||||
- Historical versions remain encrypted with their original keys
|
- Historical versions remain encrypted with their original keys
|
||||||
|
|
||||||
### Hardware Integration
|
### Hardware Integration
|
||||||
|
|
||||||
- Hardware token support via PGP/GPG integration
|
- Hardware token support via PGP/GPG integration
|
||||||
|
- macOS Keychain integration for system-level security
|
||||||
|
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Basic Workflow
|
### Basic Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize with a new mnemonic
|
# Initialize with a new mnemonic
|
||||||
secret generate mnemonic # Copy the output
|
secret generate mnemonic # Copy the output
|
||||||
@@ -259,9 +351,13 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
|
|||||||
secret list
|
secret list
|
||||||
secret get database/prod/password
|
secret get database/prod/password
|
||||||
secret get services/api/key
|
secret get services/api/key
|
||||||
|
|
||||||
|
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret remove ssh/servers/web01
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-vault Setup
|
### Multi-vault Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create separate vaults for different contexts
|
# Create separate vaults for different contexts
|
||||||
secret vault create work
|
secret vault create work
|
||||||
@@ -270,7 +366,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,22 +374,44 @@ 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)
|
||||||
|
secret unlocker add secure-enclave # macOS Secure Enclave (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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate an Age key and store it as a secret
|
# Generate an Age key and store it as a secret
|
||||||
secret generate secret encryption/mykey
|
secret generate secret encryption/mykey
|
||||||
@@ -310,33 +428,35 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
### Cryptographic Primitives
|
### Cryptographic Primitives
|
||||||
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
||||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
||||||
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
|
|
||||||
- **Authentication**: Poly1305 MAC
|
- **Authentication**: Poly1305 MAC
|
||||||
- **Hashing**: Double SHA-256 for public key identification
|
- **Hashing**: Double SHA-256 for public key identification
|
||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
- **Age Files**: Standard Age encryption format (.age extension)
|
- **age Files**: Standard age encryption format (.age extension)
|
||||||
- **Metadata**: 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
|
||||||
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic
|
|
||||||
|
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
|
||||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
|
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
|
||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
||||||
|
|
||||||
### Cross-Platform Support
|
### Cross-Platform Support
|
||||||
- **macOS**: Full support including Keychain integration
|
|
||||||
- **Linux**: Full support (excluding Keychain features)
|
- **macOS**: Full support including Keychain and Secure Enclave integration
|
||||||
- **Windows**: Basic support (filesystem operations only)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Threat Model
|
### Threat Model
|
||||||
|
|
||||||
- Protects against unauthorized access to secret values
|
- Protects against unauthorized access to secret values
|
||||||
- Provides defense against compromise of individual components
|
- Provides defense against compromise of individual components
|
||||||
- Supports hardware-backed authentication where available
|
- Supports hardware-backed authentication where available
|
||||||
|
|
||||||
### Best Practices
|
### Best Practices
|
||||||
|
|
||||||
1. Use strong, unique passphrases for unlockers
|
1. Use strong, unique passphrases for unlockers
|
||||||
2. Enable hardware authentication (Keychain, hardware tokens) when available
|
2. Enable hardware authentication (Keychain, hardware tokens) when available
|
||||||
3. Regularly audit unlockers and remove unused ones
|
3. Regularly audit unlockers and remove unused ones
|
||||||
@@ -344,6 +464,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
5. Use separate vaults for different security contexts
|
5. Use separate vaults for different security contexts
|
||||||
|
|
||||||
### Limitations
|
### Limitations
|
||||||
|
|
||||||
- Requires access to unlockers for secret retrieval
|
- Requires access to unlockers for secret retrieval
|
||||||
- Mnemonic phrases must be securely stored and backed up
|
- Mnemonic phrases must be securely stored and backed up
|
||||||
- Hardware features limited to supported platforms
|
- Hardware features limited to supported platforms
|
||||||
@@ -367,9 +488,21 @@ 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, macOS Keychain, and Secure Enclave 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)
|
||||||
|
|
||||||
|
|||||||
58
TODO.md
58
TODO.md
@@ -4,24 +4,56 @@ 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).
|
||||||
|
|
||||||
## Code Cleanups
|
## CRITICAL BLOCKERS FOR 1.0 RELEASE
|
||||||
|
|
||||||
* none of the integration tests should be searching for a binary or trying
|
### Command Injection Vulnerabilities
|
||||||
to execute another process. the integration tests cannot make another
|
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
|
||||||
process or depend on a compiled file, they must do all of their testing in
|
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
|
||||||
the current (test) process.
|
|
||||||
|
### Memory Security Critical Issues
|
||||||
|
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
|
||||||
|
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
|
||||||
|
|
||||||
|
### Race Conditions (Data Corruption Risk)
|
||||||
|
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
|
||||||
|
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
|
||||||
|
|
||||||
|
### Input Validation Vulnerabilities
|
||||||
|
- [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding
|
||||||
|
- [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion
|
||||||
|
|
||||||
|
### Timing Attack Vulnerabilities
|
||||||
|
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
|
||||||
|
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
|
||||||
|
|
||||||
|
## CRITICAL MEMORY SECURITY ISSUES
|
||||||
|
|
||||||
|
### Functions accepting bare []byte for sensitive data
|
||||||
|
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
|
||||||
|
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
|
||||||
|
### Functions returning unprotected secrets
|
||||||
|
- [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally
|
||||||
|
- [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer
|
||||||
|
|
||||||
|
### Intermediate string variables for passphrases
|
||||||
|
- [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter
|
||||||
|
- [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer
|
||||||
|
|
||||||
|
### Unprotected buffer.Bytes() usage
|
||||||
|
- [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function
|
||||||
|
- [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes
|
||||||
|
|
||||||
|
## Code Cleanups
|
||||||
|
|
||||||
* we shouldn't be passing around a statedir, it should be read from the
|
* we shouldn't be passing around a statedir, it should be read from the
|
||||||
environment or default.
|
environment or default.
|
||||||
|
|
||||||
## CRITICAL SECURITY ISSUES - Must Fix Before 1.0
|
|
||||||
|
|
||||||
- [ ] **1. Memory security vulnerabilities**: Sensitive data (passwords,
|
|
||||||
private keys, passphrases) stored as strings are not properly zeroed from
|
|
||||||
memory after use. Memory dumps or swap files could expose secrets. Found
|
|
||||||
in crypto.go:107, passphraseunlocker.go:29-48, cli/crypto.go:89,193,
|
|
||||||
pgpunlocker.go:278, keychainunlocker.go:252,346.
|
|
||||||
|
|
||||||
## HIGH PRIORITY SECURITY ISSUES
|
## HIGH PRIORITY SECURITY ISSUES
|
||||||
|
|
||||||
- [ ] **4. Application crashes on corrupted metadata**: Code panics instead
|
- [ ] **4. Application crashes on corrupted metadata**: Code panics instead
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package main is the entry point for the secret CLI application.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.eeqj.de/sneak/secret/internal/cli"
|
import "git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -4,10 +4,12 @@ go 1.24.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/age v1.2.1
|
filippo.io/age v1.2.1
|
||||||
|
github.com/awnumar/memguard v0.22.5
|
||||||
github.com/btcsuite/btcd v0.24.2
|
github.com/btcsuite/btcd v0.24.2
|
||||||
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
|
||||||
@@ -18,10 +20,15 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/awnumar/memcall v0.2.0 // indirect
|
||||||
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
|
||||||
|
|||||||
18
go.sum
18
go.sum
@@ -3,6 +3,10 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZ
|
|||||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||||
|
github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI=
|
||||||
|
github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo=
|
||||||
|
github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI=
|
||||||
|
github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE=
|
||||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||||
@@ -39,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=
|
||||||
@@ -59,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=
|
||||||
@@ -107,11 +122,14 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
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=
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
|
// Package cli implements the command-line interface for the secret application.
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global scanner for consistent stdin reading
|
|
||||||
var stdinScanner *bufio.Scanner //nolint:gochecknoglobals // Needed for consistent stdin handling
|
|
||||||
|
|
||||||
// Instance encapsulates all CLI functionality and state
|
// Instance encapsulates all CLI functionality and state
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
@@ -24,22 +17,30 @@ type Instance struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||||
func NewCLIInstance() *Instance {
|
func NewCLIInstance() (*Instance, error) {
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
stateDir := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &Instance{
|
return &Instance{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: stateDir,
|
stateDir: stateDir,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
||||||
func NewCLIInstanceWithFs(fs afero.Fs) *Instance {
|
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||||
stateDir := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &Instance{
|
return &Instance{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: stateDir,
|
stateDir: stateDir,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
||||||
@@ -65,29 +66,7 @@ func (cli *Instance) GetStateDir() string {
|
|||||||
return cli.stateDir
|
return cli.stateDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
|
// Print outputs to the command's configured output writer
|
||||||
func getStdinScanner() *bufio.Scanner {
|
func (cli *Instance) Print(a ...interface{}) (n int, err error) {
|
||||||
if stdinScanner == nil {
|
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ func TestCLIInstanceStateDir(t *testing.T) {
|
|||||||
func TestCLIInstanceWithFs(t *testing.T) {
|
func TestCLIInstanceWithFs(t *testing.T) {
|
||||||
// Test creating CLI instance with custom filesystem
|
// Test creating CLI instance with custom filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
cli := NewCLIInstanceWithFs(fs)
|
cli, err := NewCLIInstanceWithFs(fs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// The state directory should be determined automatically
|
// The state directory should be determined automatically
|
||||||
stateDir := cli.GetStateDir()
|
stateDir := cli.GetStateDir()
|
||||||
@@ -41,15 +44,21 @@ func TestDetermineStateDir(t *testing.T) {
|
|||||||
testEnvDir := "/test-env-dir"
|
testEnvDir := "/test-env-dir"
|
||||||
t.Setenv(secret.EnvStateDir, testEnvDir)
|
t.Setenv(secret.EnvStateDir, testEnvDir)
|
||||||
|
|
||||||
stateDir := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
if stateDir != testEnvDir {
|
if stateDir != testEnvDir {
|
||||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with custom config dir
|
// Test with custom config dir
|
||||||
os.Unsetenv(secret.EnvStateDir)
|
_ = os.Unsetenv(secret.EnvStateDir)
|
||||||
customConfigDir := "/custom-config"
|
customConfigDir := "/custom-config"
|
||||||
stateDir = secret.DetermineStateDir(customConfigDir)
|
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||||
if stateDir != expectedDir {
|
if stateDir != expectedDir {
|
||||||
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
|
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
205
internal/cli/completions.go
Normal file
205
internal/cli/completions.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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 {
|
||||||
|
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
|
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
// Create the appropriate unlocker instance
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
switch metadata.Type {
|
||||||
|
case "passphrase":
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
case "keychain":
|
||||||
|
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
case "pgp":
|
||||||
|
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker != nil {
|
||||||
|
id := unlocker.GetID()
|
||||||
|
if strings.HasPrefix(id, toComplete) {
|
||||||
|
completions = append(completions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVaultNamesCompletionFunc returns a completion function that provides vault names
|
||||||
|
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
var completions []string
|
||||||
|
for _, v := range vaults {
|
||||||
|
if strings.HasPrefix(v, toComplete) {
|
||||||
|
completions = append(completions, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
|
||||||
|
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
|
||||||
|
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
var completions []string
|
||||||
|
|
||||||
|
// Check if we're completing after a vault: prefix
|
||||||
|
if strings.Contains(toComplete, ":") {
|
||||||
|
// Complete secret names for the specified vault
|
||||||
|
const vaultSecretParts = 2
|
||||||
|
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||||
|
vaultName := parts[0]
|
||||||
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
secrets, err := vlt.ListSecrets()
|
||||||
|
if err == nil {
|
||||||
|
for _, secretName := range secrets {
|
||||||
|
if strings.HasPrefix(secretName, secretPrefix) {
|
||||||
|
completions = append(completions, vaultName+":"+secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete vault names with ":" suffix
|
||||||
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, v := range vaults {
|
||||||
|
if strings.HasPrefix(v, toComplete) {
|
||||||
|
completions = append(completions, v+":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also complete secrets from current vault (for within-vault moves)
|
||||||
|
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
|
||||||
|
secrets, err := currentVlt.ListSecrets()
|
||||||
|
if err == nil {
|
||||||
|
for _, secretName := range secrets {
|
||||||
|
if strings.HasPrefix(secretName, toComplete) {
|
||||||
|
completions = append(completions, secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions, cobra.ShellCompDirectiveNoSpace
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"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/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,14 +22,19 @@ func newEncryptCmd() *cobra.Command {
|
|||||||
inputFile, _ := cmd.Flags().GetString("input")
|
inputFile, _ := cmd.Flags().GetString("input")
|
||||||
outputFile, _ := cmd.Flags().GetString("output")
|
outputFile, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +48,19 @@ func newDecryptCmd() *cobra.Command {
|
|||||||
inputFile, _ := cmd.Flags().GetString("input")
|
inputFile, _ := cmd.Flags().GetString("input")
|
||||||
outputFile, _ := cmd.Flags().GetString("output")
|
outputFile, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,46 +81,46 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
||||||
// Secret exists, get the age secret key from it
|
|
||||||
var secretValue []byte
|
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
|
||||||
secretValue, err = secretObj.GetValue(nil)
|
|
||||||
} else {
|
|
||||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
|
||||||
if unlockErr != nil {
|
|
||||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
|
||||||
}
|
|
||||||
secretValue, err = secretObj.GetValue(unlocker)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ageSecretKey = string(secretValue)
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
|
||||||
if !isValidAgeSecretKey(ageSecretKey) {
|
|
||||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Secret doesn't exist, generate new age key and store it
|
// Secret doesn't exist, generate new age key and store it
|
||||||
identity, err := age.GenerateX25519Identity()
|
identity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate age key: %w", err)
|
return fmt.Errorf("failed to generate age key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ageSecretKey = identity.String()
|
// Store the generated key directly in a secure buffer
|
||||||
|
identityStr := identity.String()
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(identityStr))
|
||||||
|
defer secureBuffer.Destroy()
|
||||||
|
|
||||||
// Store the generated key as a secret
|
// Set ageSecretKey for later use (we need it for encryption)
|
||||||
err = vlt.AddSecret(secretName, []byte(ageSecretKey), false)
|
ageSecretKey = identityStr
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to store age key: %w", err)
|
return fmt.Errorf("failed to store age key: %w", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Secret exists, get the age secret key from it
|
||||||
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
|
}
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
ageSecretKey = secretBuffer.String()
|
||||||
|
|
||||||
|
// Validate that it's a valid age secret key
|
||||||
|
if !isValidAgeSecretKey(ageSecretKey) {
|
||||||
|
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the secret key
|
// Parse the secret key using secure buffer
|
||||||
identity, err := age.ParseX25519Identity(ageSecretKey)
|
finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
|
||||||
|
defer finalSecureBuffer.Destroy()
|
||||||
|
|
||||||
|
identity, err := age.ParseX25519Identity(finalSecureBuffer.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)
|
||||||
}
|
}
|
||||||
@@ -124,18 +135,18 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
var output io.Writer = cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,29 +187,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()
|
||||||
ageSecretKey := string(secretValue)
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(ageSecretKey) {
|
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(ageSecretKey)
|
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)
|
||||||
}
|
}
|
||||||
@@ -210,18 +220,18 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
var output io.Writer = cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,5 +251,20 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
||||||
func isValidAgeSecretKey(key string) bool {
|
func isValidAgeSecretKey(key string) bool {
|
||||||
_, err := age.ParseX25519Identity(key)
|
_, err := age.ParseX25519Identity(key)
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||||
|
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
||||||
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
|
return secretObj.GetValue(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretObj.GetValue(unlocker)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSecretLength = 16
|
||||||
|
mnemonicEntropyBits = 128
|
||||||
|
)
|
||||||
|
|
||||||
func newGenerateCmd() *cobra.Command {
|
func newGenerateCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "generate",
|
Use: "generate",
|
||||||
@@ -31,8 +37,12 @@ func newGenerateMnemonicCmd() *cobra.Command {
|
|||||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||||
`mnemonic phrase that can be used with 'secret init' ` +
|
`mnemonic phrase that can be used with 'secret init' ` +
|
||||||
`or 'secret import'.`,
|
`or 'secret import'.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.GenerateMnemonic(cmd)
|
return cli.GenerateMnemonic(cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -49,12 +59,16 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
secretType, _ := cmd.Flags().GetString("type")
|
secretType, _ := cmd.Flags().GetString("type")
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().IntP("length", "l", 16, "Length of the generated secret (default 16)")
|
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
|
||||||
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||||
|
|
||||||
@@ -64,7 +78,7 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
// GenerateMnemonic generates a random BIP39 mnemonic phrase
|
// GenerateMnemonic generates a random BIP39 mnemonic phrase
|
||||||
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||||
// Generate 128 bits of entropy for a 12-word mnemonic
|
// Generate 128 bits of entropy for a 12-word mnemonic
|
||||||
entropy, err := bip39.NewEntropy(128)
|
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate entropy: %w", err)
|
return fmt.Errorf("failed to generate entropy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -129,23 +143,30 @@ func (cli *Instance) GenerateSecret(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := vlt.AddSecret(secretName, []byte(secretValue), force); err != nil {
|
// Protect the generated secret immediately
|
||||||
|
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomBase58 generates a random base58 string of the specified length
|
// generateRandomBase58 generates a random base58 string of the specified length
|
||||||
func generateRandomBase58(length int) (string, error) {
|
func generateRandomBase58(length int) (string, error) {
|
||||||
const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||||
|
|
||||||
return generateRandomString(length, base58Chars)
|
return generateRandomString(length, base58Chars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomAlnum generates a random alphanumeric string of the specified length
|
// generateRandomAlnum generates a random alphanumeric string of the specified length
|
||||||
func generateRandomAlnum(length int) (string, error) {
|
func generateRandomAlnum(length int) (string, error) {
|
||||||
const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
return generateRandomString(length, alnumChars)
|
return generateRandomString(length, alnumChars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
165
internal/cli/info.go
Normal file
165
internal/cli/info.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"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, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
88
internal/cli/info_helper.go
Normal file
88
internal/cli/info_helper.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"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 {
|
||||||
|
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,16 +2,16 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"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"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
)
|
)
|
||||||
@@ -27,8 +27,12 @@ func NewInitCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunInit is the exported function that handles the init command
|
// RunInit is the exported function that handles the init command
|
||||||
func RunInit(cmd *cobra.Command, args []string) error {
|
func RunInit(cmd *cobra.Command, _ []string) error {
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.Init(cmd)
|
return cli.Init(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||||
secret.Debug("Failed to create state directory", "error", err)
|
secret.Debug("Failed to create state directory", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create state directory: %w", err)
|
return fmt.Errorf("failed to create state directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +62,22 @@ 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 == "" {
|
||||||
secret.Debug("Empty mnemonic provided")
|
secret.Debug("Empty mnemonic provided")
|
||||||
|
|
||||||
return fmt.Errorf("mnemonic cannot be empty")
|
return fmt.Errorf("mnemonic cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +85,18 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||||
|
|
||||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mnemonic in environment for CreateVault to use
|
// Set mnemonic in environment for CreateVault to use
|
||||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
defer func() {
|
defer func() {
|
||||||
if originalMnemonic != "" {
|
if originalMnemonic != "" {
|
||||||
os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
} else {
|
} else {
|
||||||
os.Unsetenv(secret.EnvMnemonic)
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -94,6 +105,7 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create default vault: %w", err)
|
return fmt.Errorf("failed to create default vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +114,7 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to load vault metadata", "error", err)
|
secret.Debug("Failed to load vault metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +122,7 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to derive long-term key", "error", err)
|
secret.Debug("Failed to derive long-term key", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
@@ -117,54 +131,33 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Prompt for passphrase for unlocker
|
// Prompt for passphrase for unlocker
|
||||||
var passphraseStr string
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
passphraseStr = envPassphrase
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
} else {
|
} else {
|
||||||
secret.Debug("Prompting user for unlock passphrase")
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
secret.Debug("Failed to read unlock passphrase", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt long-term private key to the unlocker
|
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
|
||||||
unlockerDir := passphraseUnlocker.GetDirectory()
|
// private key to longterm.age, so no need to do it again here.
|
||||||
|
|
||||||
// Read unlocker public key
|
|
||||||
unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read unlocker public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse unlocker public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt long-term private key to unlocker
|
|
||||||
ltPrivKeyData := []byte(ltIdentity.String())
|
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockerRecipient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write encrypted long-term private key
|
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
if err := afero.WriteFile(cli.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd.Printf("\nDefault vault created and configured\n")
|
cmd.Printf("\nDefault vault created and configured\n")
|
||||||
@@ -180,23 +173,33 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
||||||
// This version adds confirmation (read twice) for creating new unlockers
|
// This version adds confirmation (read twice) for creating new unlockers
|
||||||
func readSecurePassphrase(prompt string) (string, error) {
|
// Returns a LockedBuffer containing the passphrase
|
||||||
|
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||||
// Get the first passphrase
|
// Get the first passphrase
|
||||||
passphrase1, err := secret.ReadPassphrase(prompt)
|
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read confirmation passphrase
|
// Read confirmation passphrase
|
||||||
passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
passphraseBuffer1.Destroy()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare passphrases
|
// Compare passphrases
|
||||||
if passphrase1 != passphrase2 {
|
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
||||||
return "", fmt.Errorf("passphrases do not match")
|
passphraseBuffer1.Destroy()
|
||||||
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("passphrases do not match")
|
||||||
}
|
}
|
||||||
|
|
||||||
return passphrase1, nil
|
// Clean up the second buffer, we'll return the first
|
||||||
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
|
// Return the first buffer (caller is responsible for destroying it)
|
||||||
|
return passphraseBuffer1, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,14 +57,14 @@ func TestMain(m *testing.M) {
|
|||||||
// all functionality of the secret manager using a real filesystem in a temporary directory.
|
// all functionality of the secret manager using a real filesystem in a temporary directory.
|
||||||
// This test serves as both validation and documentation of the program's behavior.
|
// This test serves as both validation and documentation of the program's behavior.
|
||||||
func TestSecretManagerIntegration(t *testing.T) {
|
func TestSecretManagerIntegration(t *testing.T) {
|
||||||
// Enable debug logging to diagnose issues
|
// Only enable debug logging if running with -v flag
|
||||||
|
if testing.Verbose() {
|
||||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||||
|
|
||||||
// Reinitialize debug logging to pick up the environment variable change
|
// Reinitialize debug logging to pick up the environment variable change
|
||||||
secret.InitDebugLogging()
|
secret.InitDebugLogging()
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -124,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
|
||||||
@@ -176,14 +182,32 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// Expected: Shows database/password with metadata
|
// Expected: Shows database/password with metadata
|
||||||
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
|
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||||
|
|
||||||
|
// Test 11b: List secrets with quiet flag
|
||||||
|
// Command: secret list -q
|
||||||
|
// Purpose: Test quiet output for scripting
|
||||||
|
// Expected: Only secret names, no headers or formatting
|
||||||
|
test11bListSecretsQuiet(t, testMnemonic, runSecret)
|
||||||
|
|
||||||
// Test 12: Add secrets with different name formats
|
// Test 12: Add secrets with different name formats
|
||||||
// Commands: Various secret names (paths, dots, underscores)
|
// Commands: Various secret names (paths, dots, underscores)
|
||||||
// Purpose: Test secret name validation and storage encoding
|
// Purpose: Test secret name validation and storage encoding
|
||||||
// Expected: Proper filesystem encoding (/ -> %)
|
// Expected: Proper filesystem encoding (/ -> %)
|
||||||
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
||||||
|
|
||||||
|
// Test 12b: Move/rename secrets
|
||||||
|
// Commands: secret move, secret mv, secret rename
|
||||||
|
// Purpose: Test moving and renaming secrets
|
||||||
|
// Expected: Secret moved to new location, old location removed
|
||||||
|
test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||||
|
|
||||||
|
// Test 12c: Cross-vault move
|
||||||
|
// Commands: secret move work:secret default, secret move work:secret default:newname
|
||||||
|
// Purpose: Test moving secrets between vaults with re-encryption
|
||||||
|
// Expected: Secret copied to destination vault with all versions, source deleted
|
||||||
|
test12cCrossVaultMove(t, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
||||||
|
|
||||||
// Test 13: Unlocker management
|
// Test 13: Unlocker management
|
||||||
// Commands: secret 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/
|
||||||
@@ -266,7 +290,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// Test 26: Large secret values
|
// Test 26: Large secret values
|
||||||
// Purpose: Test with large secret values (e.g., certificates)
|
// Purpose: Test with large secret values (e.g., certificates)
|
||||||
// Expected: Proper storage and retrieval
|
// Expected: Proper storage and retrieval
|
||||||
test26LargeSecrets(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv)
|
test26LargeSecrets(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin)
|
||||||
|
|
||||||
// Test 27: Special characters in values
|
// Test 27: Special characters in values
|
||||||
// Purpose: Test secrets with newlines, unicode, binary data
|
// Purpose: Test secrets with newlines, unicode, binary data
|
||||||
@@ -314,16 +338,12 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
defaultVaultDir := filepath.Join(vaultsDir, "default")
|
defaultVaultDir := filepath.Join(vaultsDir, "default")
|
||||||
verifyFileExists(t, defaultVaultDir)
|
verifyFileExists(t, defaultVaultDir)
|
||||||
|
|
||||||
// Check currentvault symlink - it may be absolute or relative
|
// Check currentvault file contains the vault name
|
||||||
currentVaultLink := filepath.Join(tempDir, "currentvault")
|
currentVaultFile := filepath.Join(tempDir, "currentvault")
|
||||||
target, err := os.Readlink(currentVaultLink)
|
targetBytes, err := os.ReadFile(currentVaultFile)
|
||||||
require.NoError(t, err, "should be able to read currentvault symlink")
|
require.NoError(t, err, "should be able to read currentvault file")
|
||||||
// Check if it points to the right place (handle both absolute and relative)
|
target := string(targetBytes)
|
||||||
if filepath.IsAbs(target) {
|
assert.Equal(t, "default", target, "currentvault should contain vault name")
|
||||||
assert.Equal(t, filepath.Join(tempDir, "vaults.d/default"), target)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, "vaults.d/default", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify vault structure
|
// Verify vault structure
|
||||||
pubKeyFile := filepath.Join(defaultVaultDir, "pub.age")
|
pubKeyFile := filepath.Join(defaultVaultDir, "pub.age")
|
||||||
@@ -348,22 +368,12 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
||||||
verifyFileExists(t, encryptedLTPubKey)
|
verifyFileExists(t, encryptedLTPubKey)
|
||||||
|
|
||||||
// Check current-unlocker file
|
// Check current-unlocker file contains the relative path
|
||||||
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
||||||
verifyFileExists(t, currentUnlockerFile)
|
verifyFileExists(t, currentUnlockerFile)
|
||||||
|
|
||||||
// Read the current-unlocker symlink to see what it points to
|
|
||||||
symlinkTarget, err := os.Readlink(currentUnlockerFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
|
|
||||||
// Fallback to reading as file if it's not a symlink
|
|
||||||
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
||||||
t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent))
|
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
|
||||||
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
|
|
||||||
} else {
|
|
||||||
t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
|
|
||||||
assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify vault-metadata.json in vault
|
// Verify vault-metadata.json in vault
|
||||||
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
||||||
@@ -380,8 +390,8 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
t.Logf("Parsed metadata: %+v", metadata)
|
t.Logf("Parsed metadata: %+v", metadata)
|
||||||
|
|
||||||
// Verify metadata fields
|
// Verify metadata fields
|
||||||
assert.Equal(t, float64(0), metadata["derivation_index"], "first vault should have index 0")
|
assert.Equal(t, float64(0), metadata["derivationIndex"], "first vault should have index 0")
|
||||||
assert.Contains(t, metadata, "public_key_hash", "should contain public key hash")
|
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||||
assert.Contains(t, metadata, "createdAt", "should contain creation timestamp")
|
assert.Contains(t, metadata, "createdAt", "should contain creation timestamp")
|
||||||
|
|
||||||
// Verify the longterm.age file in passphrase unlocker
|
// Verify the longterm.age file in passphrase unlocker
|
||||||
@@ -411,8 +421,8 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
|
|||||||
require.NoError(t, err, "JSON output should be valid")
|
require.NoError(t, err, "JSON output should be valid")
|
||||||
|
|
||||||
// Verify current vault
|
// Verify current vault
|
||||||
currentVault, ok := response["current_vault"]
|
currentVault, ok := response["currentVault"]
|
||||||
require.True(t, ok, "response should contain current_vault")
|
require.True(t, ok, "response should contain currentVault")
|
||||||
assert.Equal(t, "default", currentVault, "current vault should be default")
|
assert.Equal(t, "default", currentVault, "current vault should be default")
|
||||||
|
|
||||||
// Verify vaults list
|
// Verify vaults list
|
||||||
@@ -439,6 +449,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")
|
||||||
@@ -448,17 +464,12 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
|
|||||||
workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
|
workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
verifyFileExists(t, workVaultDir)
|
verifyFileExists(t, workVaultDir)
|
||||||
|
|
||||||
// Check currentvault symlink was updated
|
// Check currentvault file was updated
|
||||||
currentVaultLink := filepath.Join(tempDir, "currentvault")
|
currentVaultFile := filepath.Join(tempDir, "currentvault")
|
||||||
target, err := os.Readlink(currentVaultLink)
|
targetBytes, err := os.ReadFile(currentVaultFile)
|
||||||
require.NoError(t, err, "should be able to read currentvault symlink")
|
require.NoError(t, err, "should be able to read currentvault file")
|
||||||
|
target := string(targetBytes)
|
||||||
// The symlink should now point to work vault
|
assert.Equal(t, "work", target, "currentvault should contain vault name")
|
||||||
if filepath.IsAbs(target) {
|
|
||||||
assert.Equal(t, filepath.Join(tempDir, "vaults.d/work"), target)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, "vaults.d/work", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify work vault has basic structure
|
// Verify work vault has basic structure
|
||||||
unlockersDir := filepath.Join(workVaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(workVaultDir, "unlockers.d")
|
||||||
@@ -467,9 +478,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")
|
||||||
@@ -520,14 +531,14 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
|
|||||||
require.NoError(t, err, "vault metadata should be valid JSON")
|
require.NoError(t, err, "vault metadata should be valid JSON")
|
||||||
|
|
||||||
// Work vault should have a different derivation index than default (0)
|
// Work vault should have a different derivation index than default (0)
|
||||||
derivIndex, ok := metadata["derivation_index"].(float64)
|
derivIndex, ok := metadata["derivationIndex"].(float64)
|
||||||
require.True(t, ok, "derivation_index should be a number")
|
require.True(t, ok, "derivationIndex should be a number")
|
||||||
assert.NotEqual(t, float64(0), derivIndex, "work vault should have non-zero derivation index")
|
assert.NotEqual(t, float64(0), derivIndex, "work vault should have non-zero derivation index")
|
||||||
|
|
||||||
// Verify public key hash is stored
|
// Verify public key hash is stored
|
||||||
assert.Contains(t, metadata, "public_key_hash", "should contain public key hash")
|
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||||
pubKeyHash, ok := metadata["public_key_hash"].(string)
|
pubKeyHash, ok := metadata["publicKeyHash"].(string)
|
||||||
require.True(t, ok, "public_key_hash should be a string")
|
require.True(t, ok, "publicKeyHash should be a string")
|
||||||
assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty")
|
assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,15 +595,15 @@ func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(
|
|||||||
metadataFile := filepath.Join(versionDir, "metadata.age")
|
metadataFile := filepath.Join(versionDir, "metadata.age")
|
||||||
verifyFileExists(t, metadataFile)
|
verifyFileExists(t, metadataFile)
|
||||||
|
|
||||||
// Check current symlink
|
// Check current file
|
||||||
currentLink := filepath.Join(secretDir, "current")
|
currentLink := filepath.Join(secretDir, "current")
|
||||||
verifyFileExists(t, currentLink)
|
verifyFileExists(t, currentLink)
|
||||||
|
|
||||||
// Verify symlink points to the version directory
|
// Verify current file contains the version name
|
||||||
target, err := os.Readlink(currentLink)
|
targetBytes, err := os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read current symlink")
|
require.NoError(t, err, "should read current file")
|
||||||
expectedTarget := filepath.Join("versions", versionName)
|
target := string(targetBytes)
|
||||||
assert.Equal(t, expectedTarget, target, "current symlink should point to version")
|
assert.Equal(t, versionName, target, "current file should contain version name")
|
||||||
|
|
||||||
// Verify we can retrieve the secret
|
// Verify we can retrieve the secret
|
||||||
getOutput, err := runSecretWithEnv(map[string]string{
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
@@ -674,12 +685,12 @@ func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecre
|
|||||||
verifyFileExists(t, filepath.Join(versionDir, "metadata.age"))
|
verifyFileExists(t, filepath.Join(versionDir, "metadata.age"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check current symlink points to new version
|
// Check current file points to new version
|
||||||
currentLink := filepath.Join(secretDir, "current")
|
currentLink := filepath.Join(secretDir, "current")
|
||||||
target, err := os.Readlink(currentLink)
|
targetBytes, err := os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read current symlink")
|
require.NoError(t, err, "should read current file")
|
||||||
expectedTarget := filepath.Join("versions", newVersion)
|
target := string(targetBytes)
|
||||||
assert.Equal(t, expectedTarget, target, "current symlink should point to new version")
|
assert.Equal(t, newVersion, target, "current file should contain version name")
|
||||||
|
|
||||||
// Verify we get the new value when retrieving the secret
|
// Verify we get the new value when retrieving the secret
|
||||||
getOutput, err := runSecretWithEnv(map[string]string{
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
@@ -791,9 +802,10 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
|
|||||||
|
|
||||||
// Before promotion, current should point to .002 (from test 07)
|
// Before promotion, current should point to .002 (from test 07)
|
||||||
currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current")
|
currentLink := filepath.Join(defaultVaultDir, "secrets.d", "database%password", "current")
|
||||||
target, err := os.Readlink(currentLink)
|
targetBytes, err := os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read current symlink")
|
require.NoError(t, err, "should read current file")
|
||||||
assert.Equal(t, filepath.Join("versions", version002), target, "current should initially point to .002")
|
target := string(targetBytes)
|
||||||
|
assert.Equal(t, version002, target, "current should initially point to .002")
|
||||||
|
|
||||||
// Promote the old version
|
// Promote the old version
|
||||||
output, err := runSecretWithEnv(map[string]string{
|
output, err := runSecretWithEnv(map[string]string{
|
||||||
@@ -804,11 +816,11 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
|
|||||||
assert.Contains(t, output, "Promoted version", "should confirm promotion")
|
assert.Contains(t, output, "Promoted version", "should confirm promotion")
|
||||||
assert.Contains(t, output, version001, "should mention the promoted version")
|
assert.Contains(t, output, version001, "should mention the promoted version")
|
||||||
|
|
||||||
// Verify symlink was updated
|
// Verify current file was updated
|
||||||
newTarget, err := os.Readlink(currentLink)
|
newTargetBytes, err := os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read current symlink after promotion")
|
require.NoError(t, err, "should read current file after promotion")
|
||||||
expectedTarget := filepath.Join("versions", version001)
|
newTarget := string(newTargetBytes)
|
||||||
assert.Equal(t, expectedTarget, newTarget, "current symlink should now point to .001")
|
assert.Equal(t, version001, newTarget, "current file should now point to .001")
|
||||||
|
|
||||||
// Verify we now get the old value when retrieving the secret
|
// Verify we now get the old value when retrieving the secret
|
||||||
getOutput, err := runSecretWithEnv(map[string]string{
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
@@ -876,8 +888,8 @@ func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...stri
|
|||||||
var listResponse struct {
|
var listResponse struct {
|
||||||
Secrets []struct {
|
Secrets []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
} `json:"secrets"`
|
} `json:"secrets"`
|
||||||
Filter string `json:"filter,omitempty"`
|
Filter string `json:"filter,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -900,6 +912,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) {
|
||||||
@@ -960,7 +1047,6 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
// Test invalid secret names
|
// Test invalid secret names
|
||||||
invalidNames := []string{
|
invalidNames := []string{
|
||||||
"", // empty
|
"", // empty
|
||||||
"UPPERCASE", // uppercase not allowed
|
|
||||||
"with space", // spaces not allowed
|
"with space", // spaces not allowed
|
||||||
"with@symbol", // special characters not allowed
|
"with@symbol", // special characters not allowed
|
||||||
"with#hash", // special characters not allowed
|
"with#hash", // special characters not allowed
|
||||||
@@ -986,7 +1072,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
|
|
||||||
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
|
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
|
||||||
// For now, just check the ones we know should definitely fail
|
// For now, just check the ones we know should definitely fail
|
||||||
definitelyInvalid := []string{"", "UPPERCASE", "with space", "with@symbol", "with#hash", "with$dollar"}
|
definitelyInvalid := []string{"", "with space", "with@symbol", "with#hash", "with$dollar"}
|
||||||
shouldFail := false
|
shouldFail := false
|
||||||
for _, invalid := range definitelyInvalid {
|
for _, invalid := range definitelyInvalid {
|
||||||
if invalidName == invalid {
|
if invalidName == invalid {
|
||||||
@@ -1009,15 +1095,172 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
|
// First, create a secret to move
|
||||||
|
_, err := runSecretWithStdin("original-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/original")
|
||||||
|
require.NoError(t, err, "add test/original should succeed")
|
||||||
|
|
||||||
|
// Test move command
|
||||||
|
output, err := runSecret("move", "test/original", "test/renamed")
|
||||||
|
require.NoError(t, err, "move should succeed")
|
||||||
|
assert.Contains(t, output, "Moved secret 'test/original' to 'test/renamed'", "should show move confirmation")
|
||||||
|
|
||||||
|
// Need to create a runSecretWithEnv for get operations
|
||||||
|
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
|
||||||
|
return cli.ExecuteCommandInProcess(args, "", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify original doesn't exist
|
||||||
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/original")
|
||||||
|
assert.Error(t, err, "get original should fail after move")
|
||||||
|
|
||||||
|
// Verify new location exists and has correct value
|
||||||
|
getOutput, err := runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/renamed")
|
||||||
|
require.NoError(t, err, "get renamed should succeed")
|
||||||
|
assert.Equal(t, "original-value", getOutput, "renamed secret should have original value")
|
||||||
|
|
||||||
|
// Test mv alias
|
||||||
|
_, err = runSecretWithStdin("another-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/another")
|
||||||
|
require.NoError(t, err, "add test/another should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("mv", "test/another", "test/moved-with-mv")
|
||||||
|
require.NoError(t, err, "mv alias should work")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Test rename alias
|
||||||
|
_, err = runSecretWithStdin("rename-test-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/rename-me")
|
||||||
|
require.NoError(t, err, "add test/rename-me should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("rename", "test/rename-me", "test/renamed-with-alias")
|
||||||
|
require.NoError(t, err, "rename alias should work")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Test error cases
|
||||||
|
// Try to move non-existent secret
|
||||||
|
output, err = runSecret("move", "test/nonexistent", "test/destination")
|
||||||
|
assert.Error(t, err, "move non-existent should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate source not found")
|
||||||
|
|
||||||
|
// Try to move to existing destination
|
||||||
|
_, err = runSecretWithStdin("dest-value", map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "add", "test/existing-dest")
|
||||||
|
require.NoError(t, err, "add test/existing-dest should succeed")
|
||||||
|
|
||||||
|
output, err = runSecret("move", "test/renamed", "test/existing-dest")
|
||||||
|
assert.Error(t, err, "move to existing destination should fail")
|
||||||
|
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||||
|
|
||||||
|
// Verify the source wasn't removed since move failed
|
||||||
|
getOutput, err = runSecretWithEnv(map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}, "get", "test/renamed")
|
||||||
|
require.NoError(t, err, "get source should still work after failed move")
|
||||||
|
assert.Equal(t, "original-value", getOutput, "source should still have original value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
|
env := map[string]string{
|
||||||
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test secret in the work vault
|
||||||
|
_, err := runSecretWithEnv(env, "vault", "select", "work")
|
||||||
|
require.NoError(t, err, "select work vault should succeed")
|
||||||
|
|
||||||
|
// Add a secret with a version
|
||||||
|
_, err = runSecretWithStdin("cross-vault-value-v1", env, "add", "cross/move/test")
|
||||||
|
require.NoError(t, err, "add cross/move/test should succeed")
|
||||||
|
|
||||||
|
// Add another version
|
||||||
|
_, err = runSecretWithStdin("cross-vault-value-v2", env, "add", "--force", "cross/move/test")
|
||||||
|
require.NoError(t, err, "add cross/move/test v2 should succeed")
|
||||||
|
|
||||||
|
// Move to default vault using cross-vault syntax
|
||||||
|
output, err := runSecretWithEnv(env, "move", "work:cross/move/test", "default")
|
||||||
|
require.NoError(t, err, "cross-vault move should succeed")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
assert.Contains(t, output, "2 version(s)", "should show version count")
|
||||||
|
|
||||||
|
// Verify secret exists in default vault
|
||||||
|
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||||
|
require.NoError(t, err, "select default vault should succeed")
|
||||||
|
|
||||||
|
value, err := runSecretWithEnv(env, "get", "cross/move/test")
|
||||||
|
require.NoError(t, err, "get from default vault should succeed")
|
||||||
|
assert.Equal(t, "cross-vault-value-v2", value, "should have latest version value")
|
||||||
|
|
||||||
|
// Verify secret no longer exists in work vault
|
||||||
|
_, err = runSecretWithEnv(env, "vault", "select", "work")
|
||||||
|
require.NoError(t, err, "select work vault should succeed")
|
||||||
|
|
||||||
|
_, err = runSecretWithEnv(env, "get", "cross/move/test")
|
||||||
|
assert.Error(t, err, "get from work vault should fail after move")
|
||||||
|
|
||||||
|
// Test cross-vault move with rename
|
||||||
|
_, err = runSecretWithStdin("rename-test", env, "add", "rename/source")
|
||||||
|
require.NoError(t, err, "add rename/source should succeed")
|
||||||
|
|
||||||
|
output, err = runSecretWithEnv(env, "move", "work:rename/source", "default:renamed/dest")
|
||||||
|
require.NoError(t, err, "cross-vault move with rename should succeed")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Verify renamed secret exists in default vault
|
||||||
|
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||||
|
require.NoError(t, err, "select default vault should succeed")
|
||||||
|
|
||||||
|
value, err = runSecretWithEnv(env, "get", "renamed/dest")
|
||||||
|
require.NoError(t, err, "get renamed secret should succeed")
|
||||||
|
assert.Equal(t, "rename-test", value, "should have correct value")
|
||||||
|
|
||||||
|
// Test --force flag for overwriting
|
||||||
|
_, err = runSecretWithStdin("existing-secret", env, "add", "force/test")
|
||||||
|
require.NoError(t, err, "add force/test in default should succeed")
|
||||||
|
|
||||||
|
_, err = runSecretWithEnv(env, "vault", "select", "work")
|
||||||
|
require.NoError(t, err, "select work vault should succeed")
|
||||||
|
|
||||||
|
_, err = runSecretWithStdin("new-value", env, "add", "force/test")
|
||||||
|
require.NoError(t, err, "add force/test in work should succeed")
|
||||||
|
|
||||||
|
// Move without force should fail
|
||||||
|
output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
||||||
|
assert.Error(t, err, "move without force should fail when dest exists")
|
||||||
|
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||||
|
|
||||||
|
// Move with force should succeed
|
||||||
|
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
|
||||||
|
require.NoError(t, err, "move with force should succeed")
|
||||||
|
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||||
|
|
||||||
|
// Verify value was overwritten
|
||||||
|
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||||
|
require.NoError(t, err, "select default vault should succeed")
|
||||||
|
|
||||||
|
value, err = runSecretWithEnv(env, "get", "force/test")
|
||||||
|
require.NoError(t, err, "get overwritten secret should succeed")
|
||||||
|
assert.Equal(t, "new-value", value, "should have new value after force move")
|
||||||
|
}
|
||||||
|
|
||||||
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
// Make sure we're in default vault
|
// Make sure we're in default vault
|
||||||
_, err := runSecret("vault", "select", "default")
|
_, err := runSecret("vault", "select", "default")
|
||||||
require.NoError(t, err, "vault select should succeed")
|
require.NoError(t, err, "vault select should succeed")
|
||||||
|
|
||||||
// List unlockers
|
// List unlockers
|
||||||
output, err := runSecret("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")
|
||||||
@@ -1026,15 +1269,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")
|
||||||
@@ -1050,8 +1293,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)
|
||||||
@@ -1078,27 +1321,21 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
|
|||||||
require.NoError(t, err, "vault select default should succeed")
|
require.NoError(t, err, "vault select default should succeed")
|
||||||
|
|
||||||
// Verify current vault is default
|
// Verify current vault is default
|
||||||
currentVaultLink := filepath.Join(tempDir, "currentvault")
|
currentVaultFile := filepath.Join(tempDir, "currentvault")
|
||||||
target, err := os.Readlink(currentVaultLink)
|
targetBytes, err := os.ReadFile(currentVaultFile)
|
||||||
require.NoError(t, err, "should read currentvault symlink")
|
require.NoError(t, err, "should read currentvault file")
|
||||||
if filepath.IsAbs(target) {
|
target := string(targetBytes)
|
||||||
assert.Contains(t, target, "vaults.d/default")
|
assert.Equal(t, "default", target, "currentvault should contain vault name")
|
||||||
} else {
|
|
||||||
assert.Contains(t, target, "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to work vault
|
// Switch to work vault
|
||||||
_, err = runSecret("vault", "select", "work")
|
_, err = runSecret("vault", "select", "work")
|
||||||
require.NoError(t, err, "vault select work should succeed")
|
require.NoError(t, err, "vault select work should succeed")
|
||||||
|
|
||||||
// Verify current vault is now work
|
// Verify current vault is now work
|
||||||
target, err = os.Readlink(currentVaultLink)
|
targetBytes, err = os.ReadFile(currentVaultFile)
|
||||||
require.NoError(t, err, "should read currentvault symlink")
|
require.NoError(t, err, "should read currentvault file")
|
||||||
if filepath.IsAbs(target) {
|
target = string(targetBytes)
|
||||||
assert.Contains(t, target, "vaults.d/work")
|
assert.Equal(t, "work", target, "currentvault should contain vault name")
|
||||||
} else {
|
|
||||||
assert.Contains(t, target, "work")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch back to default
|
// Switch back to default
|
||||||
_, err = runSecret("vault", "select", "default")
|
_, err = runSecret("vault", "select", "default")
|
||||||
@@ -1283,6 +1520,7 @@ func test18AgeKeyOperations(t *testing.T, tempDir, secretPath, testMnemonic stri
|
|||||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||||
}
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
return string(output), err
|
return string(output), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,6 +1587,7 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
|
|||||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||||
}
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
return string(output), err
|
return string(output), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1375,7 +1614,7 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
|
|||||||
require.NoError(t, err, "read vault metadata")
|
require.NoError(t, err, "read vault metadata")
|
||||||
|
|
||||||
var metadata struct {
|
var metadata struct {
|
||||||
DerivationIndex uint32 `json:"derivation_index"`
|
DerivationIndex uint32 `json:"derivationIndex"`
|
||||||
}
|
}
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
require.NoError(t, err, "parse vault metadata")
|
require.NoError(t, err, "parse vault metadata")
|
||||||
@@ -1443,6 +1682,7 @@ func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic str
|
|||||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||||
}
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
return string(output), err
|
return string(output), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,14 +1768,14 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
|
|||||||
err = json.Unmarshal([]byte(output), &vaultListResponse)
|
err = json.Unmarshal([]byte(output), &vaultListResponse)
|
||||||
require.NoError(t, err, "vault list JSON should be valid")
|
require.NoError(t, err, "vault list JSON should be valid")
|
||||||
assert.Contains(t, vaultListResponse, "vaults", "should have vaults key")
|
assert.Contains(t, vaultListResponse, "vaults", "should have vaults key")
|
||||||
assert.Contains(t, vaultListResponse, "current_vault", "should have current_vault key")
|
assert.Contains(t, vaultListResponse, "currentVault", "should have currentVault key")
|
||||||
|
|
||||||
// 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)) {
|
||||||
@@ -1684,7 +1924,7 @@ func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret fun
|
|||||||
// to avoid conflicts, but reads should always work
|
// to avoid conflicts, but reads should always work
|
||||||
}
|
}
|
||||||
|
|
||||||
func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), 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
|
||||||
_, 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")
|
||||||
@@ -1697,16 +1937,10 @@ func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
assert.Greater(t, len(largeValue), 10000, "should be > 10KB")
|
assert.Greater(t, len(largeValue), 10000, "should be > 10KB")
|
||||||
|
|
||||||
// Add large secret
|
// Add large secret
|
||||||
cmd := exec.Command(secretPath, "add", "large/secret", "--force")
|
_, err = runSecretWithStdin(largeValue, map[string]string{
|
||||||
cmd.Env = []string{
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
|
}, "add", "large/secret", "--force")
|
||||||
fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic),
|
require.NoError(t, err, "add large secret should succeed")
|
||||||
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
|
|
||||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
|
||||||
}
|
|
||||||
cmd.Stdin = strings.NewReader(largeValue)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, "add large secret should succeed: %s", string(output))
|
|
||||||
|
|
||||||
// Retrieve and verify
|
// Retrieve and verify
|
||||||
retrievedValue, err := runSecretWithEnv(map[string]string{
|
retrievedValue, err := runSecretWithEnv(map[string]string{
|
||||||
@@ -1722,15 +1956,9 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
|||||||
aWRnaXRzIFB0eSBMdGQwHhcNMTgwMjI4MTQwMzQ5WhcNMjgwMjI2MTQwMzQ5WjBF
|
aWRnaXRzIFB0eSBMdGQwHhcNMTgwMjI4MTQwMzQ5WhcNMjgwMjI2MTQwMzQ5WjBF
|
||||||
-----END CERTIFICATE-----`
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
cmd = exec.Command(secretPath, "add", "cert/test", "--force")
|
_, err = runSecretWithStdin(certValue, map[string]string{
|
||||||
cmd.Env = []string{
|
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||||
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
|
}, "add", "cert/test", "--force")
|
||||||
fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic),
|
|
||||||
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
|
|
||||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
|
||||||
}
|
|
||||||
cmd.Stdin = strings.NewReader(certValue)
|
|
||||||
_, err = cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, "add certificate should succeed")
|
require.NoError(t, err, "add certificate should succeed")
|
||||||
|
|
||||||
// Retrieve and verify certificate
|
// Retrieve and verify certificate
|
||||||
@@ -1818,10 +2046,10 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
|
|||||||
require.NoError(t, err, "default vault metadata should be valid JSON")
|
require.NoError(t, err, "default vault metadata should be valid JSON")
|
||||||
|
|
||||||
// Verify required fields
|
// Verify required fields
|
||||||
assert.Equal(t, float64(0), defaultMetadata["derivation_index"])
|
assert.Equal(t, float64(0), defaultMetadata["derivationIndex"])
|
||||||
assert.Contains(t, defaultMetadata, "createdAt")
|
assert.Contains(t, defaultMetadata, "createdAt")
|
||||||
assert.Contains(t, defaultMetadata, "public_key_hash")
|
assert.Contains(t, defaultMetadata, "publicKeyHash")
|
||||||
assert.Contains(t, defaultMetadata, "mnemonic_family_hash")
|
assert.Contains(t, defaultMetadata, "mnemonicFamilyHash")
|
||||||
|
|
||||||
// Check work vault metadata
|
// Check work vault metadata
|
||||||
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
||||||
@@ -1833,35 +2061,37 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
|
|||||||
require.NoError(t, err, "work vault metadata should be valid JSON")
|
require.NoError(t, err, "work vault metadata should be valid JSON")
|
||||||
|
|
||||||
// Work vault should have different derivation index
|
// Work vault should have different derivation index
|
||||||
workIndex := workMetadata["derivation_index"].(float64)
|
workIndex := workMetadata["derivationIndex"].(float64)
|
||||||
assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
|
assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
|
||||||
|
|
||||||
// Both vaults created with same mnemonic should have same mnemonic_family_hash
|
// Both vaults created with same mnemonic should have same mnemonicFamilyHash
|
||||||
assert.Equal(t, defaultMetadata["mnemonic_family_hash"], workMetadata["mnemonic_family_hash"],
|
assert.Equal(t, defaultMetadata["mnemonicFamilyHash"], workMetadata["mnemonicFamilyHash"],
|
||||||
"vaults from same mnemonic should have same mnemonic_family_hash")
|
"vaults from same mnemonic should have same mnemonicFamilyHash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
|
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
|
||||||
// Test currentvault symlink
|
// Test currentvault file
|
||||||
currentVaultLink := filepath.Join(tempDir, "currentvault")
|
currentVaultFile := filepath.Join(tempDir, "currentvault")
|
||||||
verifyFileExists(t, currentVaultLink)
|
verifyFileExists(t, currentVaultFile)
|
||||||
|
|
||||||
// Read the symlink
|
// Read the file - should contain just the vault name
|
||||||
target, err := os.Readlink(currentVaultLink)
|
targetBytes, err := os.ReadFile(currentVaultFile)
|
||||||
require.NoError(t, err, "should read currentvault symlink")
|
require.NoError(t, err, "should read currentvault file")
|
||||||
assert.Contains(t, target, "vaults.d", "should point to vaults.d directory")
|
target := string(targetBytes)
|
||||||
|
assert.NotContains(t, target, "/", "should be bare vault name without path")
|
||||||
|
|
||||||
// Test version current symlink
|
// Test version current file
|
||||||
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
|
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
|
||||||
secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password")
|
secretDir := filepath.Join(defaultVaultDir, "secrets.d", "database%password")
|
||||||
currentLink := filepath.Join(secretDir, "current")
|
currentLink := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
verifyFileExists(t, currentLink)
|
verifyFileExists(t, currentLink)
|
||||||
target, err = os.Readlink(currentLink)
|
targetBytes, err = os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read current version symlink")
|
require.NoError(t, err, "should read current version file")
|
||||||
assert.Contains(t, target, "versions", "should point to versions directory")
|
target = string(targetBytes)
|
||||||
|
assert.NotContains(t, target, "/", "should be bare version name without path")
|
||||||
|
|
||||||
// Test that symlinks update properly
|
// Test that current file updates properly
|
||||||
// Add new version
|
// Add new version
|
||||||
cmd := exec.Command(secretPath, "add", "database/password", "--force")
|
cmd := exec.Command(secretPath, "add", "database/password", "--force")
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
@@ -1874,11 +2104,12 @@ func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic strin
|
|||||||
_, err = cmd.CombinedOutput()
|
_, err = cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "add new version should succeed")
|
require.NoError(t, err, "add new version should succeed")
|
||||||
|
|
||||||
// Check that symlink was updated
|
// Check that current file was updated
|
||||||
newTarget, err := os.Readlink(currentLink)
|
newTargetBytes, err := os.ReadFile(currentLink)
|
||||||
require.NoError(t, err, "should read updated symlink")
|
require.NoError(t, err, "should read updated current file")
|
||||||
assert.NotEqual(t, target, newTarget, "symlink should point to new version")
|
newTarget := string(newTargetBytes)
|
||||||
assert.Contains(t, newTarget, "versions", "new symlink should still point to versions directory")
|
assert.NotEqual(t, target, newTarget, "current file should point to new version")
|
||||||
|
assert.NotContains(t, newTarget, "/", "new current file should be bare version name")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
@@ -1914,18 +2145,11 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d"))
|
err = copyDir(filepath.Join(tempDir, "vaults.d"), filepath.Join(backupDir, "vaults.d"))
|
||||||
require.NoError(t, err, "backup vaults should succeed")
|
require.NoError(t, err, "backup vaults should succeed")
|
||||||
|
|
||||||
// Also backup the currentvault symlink/file
|
// Also backup the currentvault file
|
||||||
currentVaultSrc := filepath.Join(tempDir, "currentvault")
|
currentVaultSrc := filepath.Join(tempDir, "currentvault")
|
||||||
currentVaultDst := filepath.Join(backupDir, "currentvault")
|
currentVaultDst := filepath.Join(backupDir, "currentvault")
|
||||||
if target, err := os.Readlink(currentVaultSrc); err == nil {
|
|
||||||
// It's a symlink, recreate it
|
|
||||||
err = os.Symlink(target, currentVaultDst)
|
|
||||||
require.NoError(t, err, "backup currentvault symlink should succeed")
|
|
||||||
} else {
|
|
||||||
// It's a regular file, copy it
|
|
||||||
data := readFile(t, currentVaultSrc)
|
data := readFile(t, currentVaultSrc)
|
||||||
writeFile(t, currentVaultDst, data)
|
writeFile(t, currentVaultDst, data)
|
||||||
}
|
|
||||||
|
|
||||||
// Add more secrets after backup
|
// Add more secrets after backup
|
||||||
cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force")
|
cmd := exec.Command(secretPath, "add", "post-backup/secret", "--force")
|
||||||
@@ -1955,13 +2179,8 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
|
|
||||||
// Restore currentvault
|
// Restore currentvault
|
||||||
os.Remove(currentVaultSrc)
|
os.Remove(currentVaultSrc)
|
||||||
if target, err := os.Readlink(currentVaultDst); err == nil {
|
restoredData := readFile(t, currentVaultDst)
|
||||||
err = os.Symlink(target, currentVaultSrc)
|
writeFile(t, currentVaultSrc, restoredData)
|
||||||
require.NoError(t, err, "restore currentvault symlink should succeed")
|
|
||||||
} else {
|
|
||||||
data := readFile(t, currentVaultDst)
|
|
||||||
writeFile(t, currentVaultSrc, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify original secrets are restored
|
// Verify original secrets are restored
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
@@ -1997,14 +2216,14 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
|
|||||||
var defaultMetadata map[string]interface{}
|
var defaultMetadata map[string]interface{}
|
||||||
err := json.Unmarshal(defaultMetadataBytes, &defaultMetadata)
|
err := json.Unmarshal(defaultMetadataBytes, &defaultMetadata)
|
||||||
require.NoError(t, err, "default vault metadata should be valid JSON")
|
require.NoError(t, err, "default vault metadata should be valid JSON")
|
||||||
assert.Equal(t, float64(0), defaultMetadata["derivation_index"], "default vault should have index 0")
|
assert.Equal(t, float64(0), defaultMetadata["derivationIndex"], "default vault should have index 0")
|
||||||
|
|
||||||
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
||||||
workMetadataBytes := readFile(t, workMetadataPath)
|
workMetadataBytes := readFile(t, workMetadataPath)
|
||||||
var workMetadata map[string]interface{}
|
var workMetadata map[string]interface{}
|
||||||
err = json.Unmarshal(workMetadataBytes, &workMetadata)
|
err = json.Unmarshal(workMetadataBytes, &workMetadata)
|
||||||
require.NoError(t, err, "work vault metadata should be valid JSON")
|
require.NoError(t, err, "work vault metadata should be valid JSON")
|
||||||
assert.Equal(t, float64(1), workMetadata["derivation_index"], "work vault should have index 1")
|
assert.Equal(t, float64(1), workMetadata["derivationIndex"], "work vault should have index 1")
|
||||||
|
|
||||||
// Switch to work vault
|
// Switch to work vault
|
||||||
_, err = runSecret("vault", "select", "work")
|
_, err = runSecret("vault", "select", "work")
|
||||||
@@ -2076,6 +2295,7 @@ func readFile(t *testing.T, path string) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
require.NoError(t, err, "Should be able to read file: %s", path)
|
require.NoError(t, err, "Should be able to read file: %s", path)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2102,18 +2322,7 @@ func copyDir(src, dst string) error {
|
|||||||
srcPath := filepath.Join(src, entry.Name())
|
srcPath := filepath.Join(src, entry.Name())
|
||||||
dstPath := filepath.Join(dst, entry.Name())
|
dstPath := filepath.Join(dst, entry.Name())
|
||||||
|
|
||||||
// Check if it's a symlink
|
if entry.IsDir() {
|
||||||
if info, err := os.Lstat(srcPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
// It's a symlink - read and recreate it
|
|
||||||
target, err := os.Readlink(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = os.Symlink(target, dstPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if entry.IsDir() {
|
|
||||||
err = copyDir(srcPath, dstPath)
|
err = copyDir(srcPath, dstPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2125,6 +2334,7 @@ func copyDir(src, dst string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,17 @@ 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")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,36 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"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/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// vaultSecretSeparator is the delimiter between vault name and secret name
|
||||||
|
vaultSecretSeparator = ":"
|
||||||
|
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||||
|
vaultSecretParts = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||||
|
// Returns (vaultName, secretName, isQualified)
|
||||||
|
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||||
|
func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) {
|
||||||
|
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
||||||
|
if len(parts) == vaultSecretParts {
|
||||||
|
return parts[0], parts[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ref, false
|
||||||
|
}
|
||||||
|
|
||||||
func newAddCmd() *cobra.Command {
|
func newAddCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <secret-name>",
|
Use: "add <secret-name>",
|
||||||
@@ -23,30 +45,45 @@ func newAddCmd() *cobra.Command {
|
|||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
secret.Debug("Got force flag", "force", force)
|
secret.Debug("Got force flag", "force", force)
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
cli.cmd = cmd // Set the command for stdin access
|
cli.cmd = cmd // Set the command for stdin access
|
||||||
secret.Debug("Created CLI instance, calling AddSecret")
|
secret.Debug("Created CLI instance, calling AddSecret")
|
||||||
|
|
||||||
return cli.AddSecret(args[0], force)
|
return cli.AddSecret(args[0], force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGetCmd() *cobra.Command {
|
func newGetCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
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, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.GetSecretWithVersion(cmd, args[0], version)
|
return cli.GetSecretWithVersion(cmd, args[0], version)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
|
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,18 +96,25 @@ 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 {
|
||||||
filter = args[0]
|
filter = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
return cli.ListSecrets(cmd, jsonOutput, filter)
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +128,11 @@ func newImportCmd() *cobra.Command {
|
|||||||
sourceFile, _ := cmd.Flags().GetString("source")
|
sourceFile, _ := cmd.Flags().GetString("source")
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -92,9 +140,91 @@ func newImportCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringP("source", "s", "", "Source file to import from (required)")
|
cmd.Flags().StringP("source", "s", "", "Source file to import from (required)")
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||||
_ = cmd.MarkFlagRequired("source")
|
_ = cmd.MarkFlagRequired("source")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newRemoveCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
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, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.RemoveSecret(cmd, args[0], false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMoveCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "move <source> <destination>",
|
||||||
|
Aliases: []string{"mv", "rename"},
|
||||||
|
Short: "Move or rename a secret",
|
||||||
|
Long: `Move a secret within a vault or between vaults.
|
||||||
|
|
||||||
|
For within-vault moves (rename):
|
||||||
|
secret move old-name new-name
|
||||||
|
|
||||||
|
For cross-vault moves:
|
||||||
|
secret move source-vault:secret-name dest-vault
|
||||||
|
secret move source-vault:secret-name dest-vault:new-name
|
||||||
|
|
||||||
|
Cross-vault moves copy ALL versions of the secret, preserving history.
|
||||||
|
The source secret is deleted after successful copy.`,
|
||||||
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
// Complete vault:secret format
|
||||||
|
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.MoveSecret(cmd, args[0], args[1], force)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateBufferSize updates the buffer size based on usage pattern
|
||||||
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||||
|
*sameSize++
|
||||||
|
const doubleAfterBuffers = 2
|
||||||
|
const growthFactor = 2
|
||||||
|
if *sameSize >= doubleAfterBuffers {
|
||||||
|
*sameSize = 0
|
||||||
|
|
||||||
|
return currentSize * growthFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSize
|
||||||
|
}
|
||||||
|
|
||||||
// AddSecret adds a secret to the current vault
|
// AddSecret adds a secret to the current vault
|
||||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||||
@@ -108,29 +238,89 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
|
|
||||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||||
|
|
||||||
// Read secret value from stdin
|
// Read secret value directly into protected buffers
|
||||||
secret.Debug("Reading secret value from stdin")
|
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||||
value, err := io.ReadAll(cli.cmd.InOrStdin())
|
|
||||||
if err != nil {
|
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||||
return fmt.Errorf("failed to read secret value: %w", err)
|
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||||
|
|
||||||
|
type bufferInfo struct {
|
||||||
|
buffer *memguard.LockedBuffer
|
||||||
|
used int
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Read secret value from stdin", "value_length", len(value))
|
var buffers []bufferInfo
|
||||||
|
defer func() {
|
||||||
|
for _, b := range buffers {
|
||||||
|
b.buffer.Destroy()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Remove trailing newline if present
|
reader := cli.cmd.InOrStdin()
|
||||||
if len(value) > 0 && value[len(value)-1] == '\n' {
|
totalSize := 0
|
||||||
value = value[:len(value)-1]
|
currentBufferSize := initialSize
|
||||||
secret.Debug("Removed trailing newline", "new_length", len(value))
|
sameSize := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Create a new buffer
|
||||||
|
buffer := memguard.NewBuffer(currentBufferSize)
|
||||||
|
n, err := io.ReadFull(reader, buffer.Bytes())
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
// No data read, destroy the unused buffer
|
||||||
|
buffer.Destroy()
|
||||||
|
} else {
|
||||||
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||||
|
totalSize += n
|
||||||
|
|
||||||
|
if totalSize > maxSize {
|
||||||
|
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we filled the buffer, consider growing for next iteration
|
||||||
|
if n == currentBufferSize {
|
||||||
|
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to read secret value: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trailing newline in the last buffer
|
||||||
|
if len(buffers) > 0 && totalSize > 0 {
|
||||||
|
lastBuffer := &buffers[len(buffers)-1]
|
||||||
|
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
||||||
|
lastBuffer.used--
|
||||||
|
totalSize--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
||||||
|
|
||||||
|
// Combine all buffers into a single protected buffer
|
||||||
|
valueBuffer := memguard.NewBuffer(totalSize)
|
||||||
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for _, b := range buffers {
|
||||||
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||||
|
offset += b.used
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the secret to the vault
|
// Add the secret to the vault
|
||||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force)
|
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
||||||
if err := vlt.AddSecret(secretName, value, force); err != nil {
|
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||||
secret.Debug("vault.AddSecret failed", "error", err)
|
secret.Debug("vault.AddSecret failed", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("vault.AddSecret completed successfully")
|
secret.Debug("vault.AddSecret completed successfully")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +333,14 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
|||||||
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||||
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
||||||
|
|
||||||
|
// Store the command for output
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current vault", "error", err)
|
secret.Debug("Failed to get current vault", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,14 +353,15 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get secret", "error", err)
|
secret.Debug("Failed to get secret", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Got secret value", "valueLength", len(value))
|
secret.Debug("Got secret value", "valueLength", len(value))
|
||||||
|
|
||||||
// Print the secret value to stdout
|
// Print the secret value to stdout
|
||||||
cmd.Print(string(value))
|
_, _ = cli.Print(string(value))
|
||||||
secret.Debug("Printed value to cmd")
|
secret.Debug("Printed value to stdout")
|
||||||
|
|
||||||
// Debug: Log what we're actually printing
|
// Debug: Log what we're actually printing
|
||||||
secret.Debug("Secret retrieval debug info",
|
secret.Debug("Secret retrieval debug info",
|
||||||
@@ -180,7 +375,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 {
|
||||||
@@ -205,7 +400,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
|
|||||||
filteredSecrets = secrets
|
filteredSecrets = secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
||||||
// For JSON output, get metadata for each secret
|
// For JSON output, get metadata for each secret
|
||||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
||||||
|
|
||||||
@@ -236,27 +431,47 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"
|
||||||
@@ -264,14 +479,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
|
||||||
@@ -285,17 +500,309 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read secret value from the source file
|
// Read secret value from the source file into protected buffers
|
||||||
value, err := afero.ReadFile(cli.fs, sourceFile)
|
file, err := cli.fs.Open(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
secret.Warn("Failed to close file", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||||
|
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||||
|
|
||||||
|
type bufferInfo struct {
|
||||||
|
buffer *memguard.LockedBuffer
|
||||||
|
used int
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffers []bufferInfo
|
||||||
|
defer func() {
|
||||||
|
for _, b := range buffers {
|
||||||
|
b.buffer.Destroy()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
totalSize := 0
|
||||||
|
currentBufferSize := initialSize
|
||||||
|
sameSize := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Create a new buffer
|
||||||
|
buffer := memguard.NewBuffer(currentBufferSize)
|
||||||
|
n, err := io.ReadFull(file, buffer.Bytes())
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
// No data read, destroy the unused buffer
|
||||||
|
buffer.Destroy()
|
||||||
|
} else {
|
||||||
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||||
|
totalSize += n
|
||||||
|
|
||||||
|
if totalSize > maxSize {
|
||||||
|
return fmt.Errorf("secret file too large: exceeds 100MB limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we filled the buffer, consider growing for next iteration
|
||||||
|
if n == currentBufferSize {
|
||||||
|
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all buffers into a single protected buffer
|
||||||
|
valueBuffer := memguard.NewBuffer(totalSize)
|
||||||
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for _, b := range buffers {
|
||||||
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||||
|
offset += b.used
|
||||||
|
}
|
||||||
|
|
||||||
// Store the secret in the vault
|
// Store the secret in the vault
|
||||||
if err := vlt.AddSecret(secretName, value, force); err != nil {
|
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSecret removes a secret from the vault
|
||||||
|
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
||||||
|
// Get current vault
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count versions for information
|
||||||
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
versionCount := 0
|
||||||
|
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||||
|
versionCount = len(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the secret directory
|
||||||
|
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveSecret moves or renames a secret (within or across vaults)
|
||||||
|
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
|
||||||
|
// Parse source and destination
|
||||||
|
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||||
|
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
||||||
|
|
||||||
|
// If neither is qualified, this is a simple within-vault rename
|
||||||
|
if !srcQualified && !destQualified {
|
||||||
|
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-vault move requires source to be qualified
|
||||||
|
if !srcQualified {
|
||||||
|
return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If destination is not qualified (no colon), check if it's a vault name
|
||||||
|
// Format: "work:secret default" means move to vault "default"
|
||||||
|
// Format: "work:secret default:newname" means move to vault "default" with new name
|
||||||
|
if !destQualified {
|
||||||
|
// Check if dest is actually a vault name
|
||||||
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v == dest {
|
||||||
|
// dest is a vault name, use source secret name
|
||||||
|
destVaultName = dest
|
||||||
|
destSecretName = srcSecretName
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If destVaultName is still empty, dest is a secret name in source vault
|
||||||
|
if destVaultName == "" {
|
||||||
|
destVaultName = srcVaultName
|
||||||
|
destSecretName = dest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If destination secret name is empty, use source secret name
|
||||||
|
if destSecretName == "" {
|
||||||
|
destSecretName = srcSecretName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same vault? Use simple rename if possible (optimization)
|
||||||
|
if srcVaultName == destVaultName {
|
||||||
|
// Select the vault and do a simple move
|
||||||
|
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
|
||||||
|
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-vault move
|
||||||
|
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveSecretWithinVault handles rename within the current vault
|
||||||
|
func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error {
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceEncoded := strings.ReplaceAll(source, "/", "%")
|
||||||
|
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||||
|
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(cli.fs, destDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
if !force {
|
||||||
|
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.fs.RemoveAll(destDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to move secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Moved secret '%s' to '%s'\n", source, dest)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveSecretCrossVault handles moving between different vaults
|
||||||
|
func (cli *Instance) moveSecretCrossVault(
|
||||||
|
cmd *cobra.Command,
|
||||||
|
srcVaultName, srcSecretName,
|
||||||
|
destVaultName, destSecretName string,
|
||||||
|
force bool,
|
||||||
|
) error {
|
||||||
|
// Get source vault
|
||||||
|
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||||
|
srcVaultDir, err := srcVault.GetDirectory()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify source vault exists
|
||||||
|
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify source secret exists
|
||||||
|
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||||
|
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get destination vault
|
||||||
|
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
||||||
|
destVaultDir, err := destVault.GetDirectory()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify destination vault exists
|
||||||
|
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||||
|
_, err = destVault.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count versions for user feedback
|
||||||
|
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
|
||||||
|
versionCount := len(versions)
|
||||||
|
|
||||||
|
// Copy all versions
|
||||||
|
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source secret
|
||||||
|
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
|
||||||
|
// Copy succeeded but delete failed - warn but don't fail
|
||||||
|
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||||
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
|
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
|
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
437
internal/cli/secrets_size_test.go
Normal file
437
internal/cli/secrets_size_test.go
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
shouldError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1KB secret",
|
||||||
|
size: 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10KB secret",
|
||||||
|
size: 10 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100KB secret",
|
||||||
|
size: 100 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1MB secret",
|
||||||
|
size: 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10MB secret",
|
||||||
|
size: 10 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "99MB secret",
|
||||||
|
size: 99 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100MB secret minus 1 byte",
|
||||||
|
size: 100*1024*1024 - 1,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "101MB secret - should fail",
|
||||||
|
size: 101 * 1024 * 1024,
|
||||||
|
shouldError: true,
|
||||||
|
errorMsg: "secret too large: exceeds 100MB limit",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vaultName := "test-vault"
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get vault and set up long-term key
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Generate test data of specified size
|
||||||
|
testData := make([]byte, tt.size)
|
||||||
|
_, err = rand.Read(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add newline that will be stripped
|
||||||
|
testDataWithNewline := append(testData, '\n')
|
||||||
|
|
||||||
|
// Create fake stdin
|
||||||
|
stdin := bytes.NewReader(testDataWithNewline)
|
||||||
|
|
||||||
|
// Create command with fake stdin
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetIn(stdin)
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = stateDir
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
// Test adding the secret
|
||||||
|
secretName := fmt.Sprintf("test-secret-%d", tt.size)
|
||||||
|
err = cli.AddSecret(secretName, false)
|
||||||
|
|
||||||
|
if tt.shouldError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original (without newline)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
shouldError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1KB file",
|
||||||
|
size: 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10KB file",
|
||||||
|
size: 10 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100KB file",
|
||||||
|
size: 100 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1MB file",
|
||||||
|
size: 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10MB file",
|
||||||
|
size: 10 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "99MB file",
|
||||||
|
size: 99 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100MB file",
|
||||||
|
size: 100 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "101MB file - should fail",
|
||||||
|
size: 101 * 1024 * 1024,
|
||||||
|
shouldError: true,
|
||||||
|
errorMsg: "secret file too large: exceeds 100MB limit",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vaultName := "test-vault"
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get vault and set up long-term key
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Generate test data of specified size
|
||||||
|
testData := make([]byte, tt.size)
|
||||||
|
_, err = rand.Read(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write test data to file
|
||||||
|
testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size)
|
||||||
|
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create command
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = stateDir
|
||||||
|
|
||||||
|
// Test importing the secret
|
||||||
|
secretName := fmt.Sprintf("imported-secret-%d", tt.size)
|
||||||
|
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||||
|
|
||||||
|
if tt.shouldError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
|
// Test various sizes that should trigger buffer growth
|
||||||
|
sizes := []int{
|
||||||
|
1, // Single byte
|
||||||
|
100, // Small
|
||||||
|
4095, // Just under initial 4KB
|
||||||
|
4096, // Exactly 4KB
|
||||||
|
4097, // Just over 4KB
|
||||||
|
8191, // Just under 8KB (first double)
|
||||||
|
8192, // Exactly 8KB
|
||||||
|
8193, // Just over 8KB
|
||||||
|
12288, // 12KB (should trigger second double)
|
||||||
|
16384, // 16KB
|
||||||
|
32768, // 32KB (after more doublings)
|
||||||
|
65536, // 64KB
|
||||||
|
131072, // 128KB
|
||||||
|
524288, // 512KB
|
||||||
|
1048576, // 1MB
|
||||||
|
2097152, // 2MB
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range sizes {
|
||||||
|
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vaultName := "test-vault"
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get vault and set up long-term key
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create test data of exactly the specified size
|
||||||
|
// Use a pattern that's easy to verify
|
||||||
|
testData := make([]byte, size)
|
||||||
|
for i := range testData {
|
||||||
|
testData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fake stdin without newline
|
||||||
|
stdin := bytes.NewReader(testData)
|
||||||
|
|
||||||
|
// Create command with fake stdin
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetIn(stdin)
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = stateDir
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
// Test adding the secret
|
||||||
|
secretName := fmt.Sprintf("buffer-test-%d", size)
|
||||||
|
err = cli.AddSecret(secretName, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||||
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vaultName := "test-vault"
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get vault and set up long-term key
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create a custom reader that simulates slow streaming input
|
||||||
|
// This will help verify our buffer handling works correctly with partial reads
|
||||||
|
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||||
|
slowReader := &slowReader{
|
||||||
|
data: testData,
|
||||||
|
chunkSize: 1000, // Read 1KB at a time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command with slow reader as stdin
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetIn(slowReader)
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = stateDir
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
// Test adding the secret
|
||||||
|
err = cli.AddSecret("streaming-test", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowReader simulates a reader that returns data in small chunks
|
||||||
|
type slowReader struct {
|
||||||
|
data []byte
|
||||||
|
offset int
|
||||||
|
chunkSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *slowReader) Read(p []byte) (n int, err error) {
|
||||||
|
if r.offset >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read at most chunkSize bytes
|
||||||
|
remaining := len(r.data) - r.offset
|
||||||
|
toRead := r.chunkSize
|
||||||
|
if toRead > remaining {
|
||||||
|
toRead = remaining
|
||||||
|
}
|
||||||
|
if toRead > len(p) {
|
||||||
|
toRead = len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
n = copy(p, r.data[r.offset:r.offset+toRead])
|
||||||
|
r.offset += n
|
||||||
|
|
||||||
|
if r.offset >= len(r.data) {
|
||||||
|
err = io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
72
internal/cli/stdout_stderr_test.go
Normal file
72
internal/cli/stdout_stderr_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret value to stdout, not stderr
|
||||||
|
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||||
|
// Create a temporary directory for our vault
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set environment variables for the test
|
||||||
|
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
||||||
|
|
||||||
|
// Find the secret binary path
|
||||||
|
wd, err := filepath.Abs("../..")
|
||||||
|
require.NoError(t, err, "should get working directory")
|
||||||
|
secretPath := filepath.Join(wd, "secret")
|
||||||
|
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
testPassphrase := "test-passphrase"
|
||||||
|
|
||||||
|
// Initialize vault
|
||||||
|
cmd := exec.Command(secretPath, "init")
|
||||||
|
cmd.Env = []string{
|
||||||
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
|
"SB_UNLOCK_PASSPHRASE=" + testPassphrase,
|
||||||
|
"PATH=" + "/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "init should succeed: %s", string(output))
|
||||||
|
|
||||||
|
// Add a secret
|
||||||
|
cmd = exec.Command(secretPath, "add", "test/secret")
|
||||||
|
cmd.Env = []string{
|
||||||
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
|
"PATH=" + "/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
cmd.Stdin = strings.NewReader("test-secret-value")
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "add should succeed: %s", string(output))
|
||||||
|
|
||||||
|
// Test that 'secret get' outputs to stdout, not stderr
|
||||||
|
cmd = exec.Command(secretPath, "get", "test/secret")
|
||||||
|
cmd.Env = []string{
|
||||||
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
|
"PATH=" + "/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
require.NoError(t, err, "get should succeed")
|
||||||
|
|
||||||
|
// The secret value should be in stdout
|
||||||
|
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()), "secret value should be in stdout")
|
||||||
|
|
||||||
|
// Nothing should be in stderr
|
||||||
|
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
|||||||
|
|
||||||
// Set test environment
|
// Set test environment
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
os.Setenv(k, v)
|
_ = os.Setenv(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create root command
|
// Create root command
|
||||||
@@ -53,9 +53,9 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
|||||||
// Restore environment
|
// Restore environment
|
||||||
for k, v := range savedEnv {
|
for k, v := range savedEnv {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
os.Unsetenv(k)
|
_ = os.Unsetenv(k)
|
||||||
} else {
|
} else {
|
||||||
os.Setenv(k, v)
|
_ = os.Setenv(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,99 +3,247 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Import from init.go
|
// UnlockerInfo represents unlocker information for display
|
||||||
|
type UnlockerInfo struct {
|
||||||
// ... existing imports ...
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
func newUnlockersCmd() *cobra.Command {
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
cmd := &cobra.Command{
|
Flags []string `json:"flags,omitempty"`
|
||||||
Use: "unlockers",
|
IsCurrent bool `json:"isCurrent"`
|
||||||
Short: "Manage unlockers",
|
|
||||||
Long: `Create, list, and remove unlockers for the current vault.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newUnlockersListCmd())
|
// Table formatting constants
|
||||||
cmd.AddCommand(newUnlockersAddCmd())
|
const (
|
||||||
cmd.AddCommand(newUnlockersRmCmd())
|
unlockerIDWidth = 40
|
||||||
|
unlockerTypeWidth = 12
|
||||||
|
unlockerDateWidth = 20
|
||||||
|
unlockerFlagsWidth = 20
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersListCmd() *cobra.Command {
|
// If no default key is configured, get the first secret key
|
||||||
cmd := &cobra.Command{
|
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||||
Use: "list",
|
output, err = cmd.Output()
|
||||||
Short: "List unlockers in the current vault",
|
if err != nil {
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
|
||||||
cli.cmd = cmd
|
|
||||||
return cli.UnlockersList(jsonOutput)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
// Parse output to find the first usable secret key
|
||||||
return cmd
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersAddCmd() *cobra.Command {
|
return "", fmt.Errorf("no GPG secret keys found")
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "add <type>",
|
|
||||||
Short: "Add a new unlocker",
|
|
||||||
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cli := NewCLIInstance()
|
|
||||||
return cli.UnlockersAdd(args[0], cmd)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newUnlockersRmCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "rm <unlocker-id>",
|
|
||||||
Short: "Remove an unlocker",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cli := NewCLIInstance()
|
|
||||||
return cli.UnlockersRemove(args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerCmd() *cobra.Command {
|
func newUnlockerCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "unlocker",
|
Use: "unlocker",
|
||||||
Short: "Manage current unlocker",
|
Short: "Manage unlockers",
|
||||||
Long: `Select the current unlocker for operations.`,
|
Long: `Create, list, and remove unlockers for the current vault.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newUnlockerSelectSubCmd())
|
cmd.AddCommand(newUnlockerListCmd())
|
||||||
|
cmd.AddCommand(newUnlockerAddCmd())
|
||||||
|
cmd.AddCommand(newUnlockerRemoveCmd())
|
||||||
|
cmd.AddCommand(newUnlockerSelectCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerSelectSubCmd() *cobra.Command {
|
func newUnlockerListCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List unlockers in the current vault",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
return cli.UnlockersList(jsonOutput)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnlockerAddCmd() *cobra.Command {
|
||||||
|
// Build the supported types list based on platform
|
||||||
|
supportedTypes := "passphrase, pgp"
|
||||||
|
typeDescriptions := `Available unlocker types:
|
||||||
|
|
||||||
|
passphrase - Traditional password-based encryption
|
||||||
|
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
||||||
|
The passphrase is never stored in plaintext.
|
||||||
|
|
||||||
|
pgp - GNU Privacy Guard (GPG) key-based encryption
|
||||||
|
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
||||||
|
Requires gpg to be installed and configured with at least one secret key.
|
||||||
|
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||||
|
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.
|
||||||
|
|
||||||
|
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
|
||||||
|
Stores the vault's master key encrypted by a non-exportable P-256 key
|
||||||
|
held in the Secure Enclave. The key never leaves the hardware.
|
||||||
|
Uses ECIES encryption; decryption is performed inside the SE.`
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add <type>",
|
||||||
|
Short: "Add a new unlocker",
|
||||||
|
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Each vault can have multiple unlockers, allowing different authentication methods
|
||||||
|
to access the same vault. This provides flexibility and backup access options.`, typeDescriptions),
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgs: strings.Split(supportedTypes, ", "),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
unlockerType := args[0]
|
||||||
|
|
||||||
|
// Validate unlocker type
|
||||||
|
validTypes := strings.Split(supportedTypes, ", ")
|
||||||
|
valid := false
|
||||||
|
for _, t := range validTypes {
|
||||||
|
if unlockerType == t {
|
||||||
|
valid = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
|
||||||
|
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if --keyid was used with non-PGP type
|
||||||
|
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
||||||
|
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.UnlockersAdd(unlockerType, cmd)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnlockerRemoveCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <unlocker-id>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove an unlocker",
|
||||||
|
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
||||||
|
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
||||||
|
`will be permanently inaccessible.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.UnlockersRemove(args[0], force, cmd)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnlockerSelectCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
cli := NewCLIInstance()
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.UnlockerSelect(args[0])
|
return cli.UnlockerSelect(args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -109,6 +257,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 {
|
||||||
@@ -116,18 +271,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
// Load actual unlocker objects to get the proper IDs
|
||||||
type UnlockerInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
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
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +285,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
secret.Warn("Could not read unlockers directory", "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +302,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
// Check if this is the right unlocker by comparing metadata
|
// Check if this is the right unlocker by comparing metadata
|
||||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // FIXME this error needs to be handled
|
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
var diskMetadata secret.UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
continue // FIXME this error needs to be handled
|
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match by type and creation time
|
// Match by type and creation time
|
||||||
@@ -168,7 +324,10 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
case "pgp":
|
case "pgp":
|
||||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
case "secure-enclave":
|
||||||
|
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +339,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
} else {
|
} else {
|
||||||
// Generate ID as fallback
|
// Generate ID as fallback
|
||||||
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||||
|
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerInfo := UnlockerInfo{
|
unlockerInfo := UnlockerInfo{
|
||||||
@@ -187,14 +347,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
|
IsCurrent: properID == currentUnlockerID,
|
||||||
}
|
}
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
unlockers = append(unlockers, unlockerInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
// JSON output
|
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.printUnlockersTable(unlockers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printUnlockersJSON prints unlockers in JSON format
|
||||||
|
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
|
||||||
output := map[string]interface{}{
|
output := map[string]interface{}{
|
||||||
"unlockers": unlockers,
|
"unlockers": unlockers,
|
||||||
|
"currentUnlockerID": currentUnlockerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||||
@@ -203,23 +372,35 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Println(string(jsonBytes))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
// printUnlockersTable prints unlockers in a formatted table
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
|
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 {
|
for _, unlocker := range unlockers {
|
||||||
flags := ""
|
flags := ""
|
||||||
if len(unlocker.Flags) > 0 {
|
if len(unlocker.Flags) > 0 {
|
||||||
flags = strings.Join(unlocker.Flags, ",")
|
flags = strings.Join(unlocker.Flags, ",")
|
||||||
}
|
}
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
|
prefix := " "
|
||||||
|
if unlocker.IsCurrent {
|
||||||
|
prefix = "* "
|
||||||
|
}
|
||||||
|
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||||
|
prefix,
|
||||||
unlocker.ID,
|
unlocker.ID,
|
||||||
unlocker.Type,
|
unlocker.Type,
|
||||||
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
@@ -227,13 +408,18 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersAdd adds a new unlocker
|
// UnlockersAdd adds a new unlocker
|
||||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||||
|
// Build the supported types list based on platform
|
||||||
|
supportedTypes := "passphrase, pgp"
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||||
|
}
|
||||||
|
|
||||||
switch unlockerType {
|
switch unlockerType {
|
||||||
case "passphrase":
|
case "passphrase":
|
||||||
// Get current vault
|
// Get current vault
|
||||||
@@ -246,26 +432,39 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
||||||
|
|
||||||
// Check if passphrase is set in environment variable
|
// Check if passphrase is set in environment variable
|
||||||
var passphraseStr string
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
passphraseStr = envPassphrase
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
} else {
|
} else {
|
||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -275,17 +474,78 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
||||||
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
|
||||||
|
|
||||||
|
case "secure-enclave":
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
||||||
|
|
||||||
|
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(seUnlocker.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)
|
||||||
@@ -295,22 +555,64 @@ 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
|
||||||
@@ -323,3 +625,81 @@ 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 {
|
||||||
|
secret.Warn("Could not list unlockers during duplicate check", "error", err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
secret.Warn("Could not get vault directory during duplicate check", "error", err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
|
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
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)
|
||||||
|
case "secure-enclave":
|
||||||
|
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker != nil && unlocker.GetID() == unlockerID {
|
||||||
|
return fmt.Errorf("unlocker already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
@@ -26,6 +29,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newVaultCreateCmd())
|
cmd.AddCommand(newVaultCreateCmd())
|
||||||
cmd.AddCommand(newVaultSelectCmd())
|
cmd.AddCommand(newVaultSelectCmd())
|
||||||
cmd.AddCommand(newVaultImportCmd())
|
cmd.AddCommand(newVaultImportCmd())
|
||||||
|
cmd.AddCommand(newVaultRemoveCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -33,16 +37,22 @@ func newVaultCmd() *cobra.Command {
|
|||||||
func newVaultListCmd() *cobra.Command {
|
func newVaultListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
Short: "List available vaults",
|
Short: "List available vaults",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.ListVaults(cmd, jsonOutput)
|
return cli.ListVaults(cmd, jsonOutput)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,42 +62,95 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
Short: "Create a new vault",
|
Short: "Create a new vault",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.CreateVault(cmd, args[0])
|
return cli.CreateVault(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVaultSelectCmd() *cobra.Command {
|
func newVaultSelectCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
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, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.SelectVault(cmd, args[0])
|
return cli.SelectVault(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVaultImportCmd() *cobra.Command {
|
func newVaultImportCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
vaultName = args[0]
|
vaultName = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cli.VaultImport(cmd, vaultName)
|
return cli.VaultImport(cmd, vaultName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newVaultRemoveCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
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, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -95,7 +158,7 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||||
// Get current vault name for context
|
// Get current vault name for context
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||||
@@ -104,7 +167,7 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"vaults": vaults,
|
"vaults": vaults,
|
||||||
"current_vault": currentVault,
|
"currentVault": currentVault,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||||
@@ -141,12 +204,96 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +304,7 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Selected vault '%s' as current\n", name)
|
cmd.Printf("Selected vault '%s' as current\n", name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +352,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get next derivation index", "error", err)
|
secret.Debug("Failed to get next derivation index", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
}
|
}
|
||||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||||
@@ -239,7 +388,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If metadata doesn't exist, create new
|
// If metadata doesn't exist, create new
|
||||||
existingMetadata = &vault.VaultMetadata{
|
existingMetadata = &vault.Metadata{
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +400,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
|
|
||||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||||
secret.Debug("Failed to save vault metadata", "error", err)
|
secret.Debug("Failed to save vault metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||||
@@ -263,14 +413,19 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
|
// Create secure buffer for passphrase
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,3 +435,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -12,9 +13,17 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tabWriterPadding = 2
|
||||||
|
)
|
||||||
|
|
||||||
// newVersionCmd returns the version management command
|
// newVersionCmd returns the version management command
|
||||||
func newVersionCmd() *cobra.Command {
|
func newVersionCmd() *cobra.Command {
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return VersionCommands(cli)
|
return VersionCommands(cli)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +38,10 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
// List versions command
|
// List versions command
|
||||||
listCmd := &cobra.Command{
|
listCmd := &cobra.Command{
|
||||||
Use: "list <secret-name>",
|
Use: "list <secret-name>",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
Short: "List all versions of a secret",
|
Short: "List all versions of a secret",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
@@ -41,13 +52,42 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Use: "promote <secret-name> <version>",
|
Use: "promote <secret-name> <version>",
|
||||||
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),
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +99,14 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current vault", "error", err)
|
secret.Debug("Failed to get current vault", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory", "error", err)
|
secret.Debug("Failed to get vault directory", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +118,12 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if secret exists", "error", err)
|
secret.Debug("Failed to check if secret exists", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found", "secret_name", secretName)
|
secret.Debug("Secret not found", "secret_name", secretName)
|
||||||
|
|
||||||
return fmt.Errorf("secret '%s' not found", secretName)
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +131,13 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
versions, err := secret.ListVersions(cli.fs, secretDir)
|
versions, err := secret.ListVersions(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to list versions", "error", err)
|
secret.Debug("Failed to list versions", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to list versions: %w", err)
|
return fmt.Errorf("failed to list versions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) == 0 {
|
if len(versions) == 0 {
|
||||||
cmd.Println("No versions found")
|
cmd.Println("No versions found")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +155,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create table writer
|
// Create table writer
|
||||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
|
||||||
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||||
|
|
||||||
// Load and display each version's metadata
|
// Load and display each version's metadata
|
||||||
for _, version := range versions {
|
for _, version := range versions {
|
||||||
@@ -118,13 +164,14 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
// Load metadata
|
// Load metadata
|
||||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||||
secret.Debug("Failed to load version metadata", "version", version, "error", err)
|
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
||||||
// Display version with error
|
// Display version with error
|
||||||
status := "error"
|
status := "error"
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
status = "current (error)"
|
status = "current (error)"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +197,11 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
_ = w.Flush()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,5 +238,63 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
||||||
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ import (
|
|||||||
"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"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
|
func addTestSecret(t *testing.T, vlt *vault.Vault, name string, value []byte, force bool) {
|
||||||
|
t.Helper()
|
||||||
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
|
defer buffer.Destroy()
|
||||||
|
err := vlt.AddSecret(name, buffer, force)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to set up a vault with long-term key
|
// Helper function to set up a vault with long-term key
|
||||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
@@ -70,13 +80,11 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
@@ -128,7 +136,7 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
|||||||
|
|
||||||
// Try to list versions of non-existent secret
|
// Try to list versions of non-existent secret
|
||||||
err := cli.ListVersions(cmd, "nonexistent/secret")
|
err := cli.ListVersions(cmd, "nonexistent/secret")
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +152,11 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -201,8 +207,7 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("value"), false)
|
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
@@ -212,7 +217,7 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Try to promote non-existent version
|
// Try to promote non-existent version
|
||||||
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +233,11 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -263,7 +266,10 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
|
|
||||||
func TestVersionCommandStructure(t *testing.T) {
|
func TestVersionCommandStructure(t *testing.T) {
|
||||||
// Test that version commands are properly structured
|
// Test that version commands are properly structured
|
||||||
cli := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
cmd := VersionCommands(cli)
|
cmd := VersionCommands(cli)
|
||||||
|
|
||||||
assert.Equal(t, "version", cmd.Use)
|
assert.Equal(t, "version", cmd.Use)
|
||||||
|
|||||||
129
internal/macse/macse_darwin.go
Normal file
129
internal/macse/macse_darwin.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
// Package macse provides Go bindings for macOS Secure Enclave operations
|
||||||
|
// using CryptoTokenKit identities created via sc_auth.
|
||||||
|
// Key creation and deletion shell out to sc_auth (which has SE entitlements).
|
||||||
|
// Encrypt/decrypt use Security.framework ECIES directly (works unsigned).
|
||||||
|
package macse
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||||
|
#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include "secure_enclave.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// p256UncompressedKeySize is the size of an uncompressed P-256 public key.
|
||||||
|
p256UncompressedKeySize = 65
|
||||||
|
|
||||||
|
// errorBufferSize is the size of the C error message buffer.
|
||||||
|
errorBufferSize = 512
|
||||||
|
|
||||||
|
// hashBufferSize is the size of the hash output buffer.
|
||||||
|
hashBufferSize = 128
|
||||||
|
|
||||||
|
// maxCiphertextSize is the max buffer for ECIES ciphertext.
|
||||||
|
// ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext.
|
||||||
|
maxCiphertextSize = 8192
|
||||||
|
|
||||||
|
// maxPlaintextSize is the max buffer for decrypted plaintext.
|
||||||
|
maxPlaintextSize = 8192
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
||||||
|
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
|
||||||
|
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||||
|
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||||
|
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||||
|
var hashBuf [hashBufferSize]C.char
|
||||||
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
|
cLabel := C.CString(label)
|
||||||
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
|
result := C.se_create_key(cLabel,
|
||||||
|
&pubKeyBuf[0], &pubKeyLen,
|
||||||
|
&hashBuf[0], C.int(hashBufferSize),
|
||||||
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
|
||||||
|
h := C.GoString(&hashBuf[0])
|
||||||
|
|
||||||
|
return pk, h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext using the SE-backed public key via ECIES
|
||||||
|
// (eciesEncryptionStandardVariableIVX963SHA256AESGCM).
|
||||||
|
// Encryption uses only the public key; no SE interaction required.
|
||||||
|
func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
||||||
|
ciphertextBuf := make([]C.uint8_t, maxCiphertextSize)
|
||||||
|
ciphertextLen := C.int(maxCiphertextSize)
|
||||||
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
|
cLabel := C.CString(label)
|
||||||
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
|
result := C.se_encrypt(cLabel,
|
||||||
|
(*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)),
|
||||||
|
&ciphertextBuf[0], &ciphertextLen,
|
||||||
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||||
|
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||||
|
func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
||||||
|
plaintextBuf := make([]C.uint8_t, maxPlaintextSize)
|
||||||
|
plaintextLen := C.int(maxPlaintextSize)
|
||||||
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
|
cLabel := C.CString(label)
|
||||||
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
|
result := C.se_decrypt(cLabel,
|
||||||
|
(*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)),
|
||||||
|
&plaintextBuf[0], &plaintextLen,
|
||||||
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth.
|
||||||
|
func DeleteKey(hash string) error {
|
||||||
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
|
cHash := C.CString(hash)
|
||||||
|
defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
|
result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
29
internal/macse/macse_stub.go
Normal file
29
internal/macse/macse_stub.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
|
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||||
|
package macse
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// CreateKey is not supported on non-darwin platforms.
|
||||||
|
func CreateKey(_ string) ([]byte, string, error) {
|
||||||
|
return nil, "", errNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt is not supported on non-darwin platforms.
|
||||||
|
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
|
return nil, errNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt is not supported on non-darwin platforms.
|
||||||
|
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
|
return nil, errNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey is not supported on non-darwin platforms.
|
||||||
|
func DeleteKey(_ string) error {
|
||||||
|
return errNotSupported
|
||||||
|
}
|
||||||
163
internal/macse/macse_test.go
Normal file
163
internal/macse/macse_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package macse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testKeyLabel = "berlin.sneak.app.secret.test.se-key"
|
||||||
|
|
||||||
|
// testKeyHash stores the hash of the created test key for cleanup.
|
||||||
|
var testKeyHash string //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// skipIfNoSecureEnclave skips the test if SE access is unavailable.
|
||||||
|
func skipIfNoSecureEnclave(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
probeLabel := "berlin.sneak.app.secret.test.se-probe"
|
||||||
|
_, hash, err := CreateKey(probeLabel)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Secure Enclave unavailable (skipping): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
_ = DeleteKey(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndDeleteKey(t *testing.T) {
|
||||||
|
skipIfNoSecureEnclave(t)
|
||||||
|
|
||||||
|
if testKeyHash != "" {
|
||||||
|
_ = DeleteKey(testKeyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, hash, err := CreateKey(testKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateKey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testKeyHash = hash
|
||||||
|
t.Logf("Created key with hash: %s", hash)
|
||||||
|
|
||||||
|
// Verify valid uncompressed P-256 public key
|
||||||
|
if len(pubKey) != p256UncompressedKeySize {
|
||||||
|
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pubKey[0] != 0x04 {
|
||||||
|
t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash == "" {
|
||||||
|
t.Fatal("expected non-empty hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the key
|
||||||
|
if err := DeleteKey(hash); err != nil {
|
||||||
|
t.Fatalf("DeleteKey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testKeyHash = ""
|
||||||
|
t.Log("Key created, verified, and deleted successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
skipIfNoSecureEnclave(t)
|
||||||
|
|
||||||
|
_, hash, err := CreateKey(testKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateKey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testKeyHash = hash
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if testKeyHash != "" {
|
||||||
|
_ = DeleteKey(testKeyHash)
|
||||||
|
testKeyHash = ""
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test data simulating an age private key
|
||||||
|
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext))
|
||||||
|
|
||||||
|
if bytes.Equal(ciphertext, plaintext) {
|
||||||
|
t.Fatal("ciphertext should differ from plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, err := Decrypt(testKeyLabel, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("decrypted data does not match original plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("ECIES encrypt/decrypt round-trip successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||||
|
skipIfNoSecureEnclave(t)
|
||||||
|
|
||||||
|
_, hash, err := CreateKey(testKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateKey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testKeyHash = hash
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if testKeyHash != "" {
|
||||||
|
_ = DeleteKey(testKeyHash)
|
||||||
|
testKeyHash = ""
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
plaintext := []byte("test-secret-data")
|
||||||
|
|
||||||
|
ct1, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ct2, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECIES uses a random ephemeral key each time, so ciphertexts should differ
|
||||||
|
if bytes.Equal(ct1, ct2) {
|
||||||
|
t.Fatal("two encryptions of same plaintext should produce different ciphertexts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should decrypt to the same plaintext
|
||||||
|
dec1, err := Decrypt(testKeyLabel, ct1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec2, err := Decrypt(testKeyLabel, ct2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) {
|
||||||
|
t.Fatal("both ciphertexts should decrypt to original plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext")
|
||||||
|
}
|
||||||
57
internal/macse/secure_enclave.h
Normal file
57
internal/macse/secure_enclave.h
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#ifndef SECURE_ENCLAVE_H
|
||||||
|
#define SECURE_ENCLAVE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
||||||
|
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||||
|
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||||
|
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||||
|
// hash_out: output buffer for the identity hash (for deletion)
|
||||||
|
// hash_out_len: size of hash_out buffer
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_create_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *hash_out, int hash_out_len,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||||
|
// label: label of the CTK identity whose public key to use
|
||||||
|
// plaintext: data to encrypt
|
||||||
|
// plaintext_len: length of plaintext
|
||||||
|
// ciphertext_out: output buffer for the ECIES ciphertext
|
||||||
|
// ciphertext_len: on input, size of buffer; on output, actual size written
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_encrypt(const char *label,
|
||||||
|
const uint8_t *plaintext, int plaintext_len,
|
||||||
|
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
// se_decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||||
|
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||||
|
// label: label of the CTK identity whose private key to use
|
||||||
|
// ciphertext: ECIES ciphertext produced by se_encrypt
|
||||||
|
// ciphertext_len: length of ciphertext
|
||||||
|
// plaintext_out: output buffer for decrypted data
|
||||||
|
// plaintext_len: on input, size of buffer; on output, actual size written
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_decrypt(const char *label,
|
||||||
|
const uint8_t *ciphertext, int ciphertext_len,
|
||||||
|
uint8_t *plaintext_out, int *plaintext_len,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth.
|
||||||
|
// hash: the identity hash returned by se_create_key
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_delete_key(const char *hash,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
#endif // SECURE_ENCLAVE_H
|
||||||
300
internal/macse/secure_enclave.m
Normal file
300
internal/macse/secure_enclave.m
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Security/Security.h>
|
||||||
|
#include "secure_enclave.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// snprintf_error writes an error message string to the output buffer.
|
||||||
|
static void snprintf_error(char *error_out, int error_out_len, NSString *msg) {
|
||||||
|
if (error_out && error_out_len > 0) {
|
||||||
|
snprintf(error_out, error_out_len, "%s", msg.UTF8String);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup_ctk_identity finds a CTK identity by label and returns the private key.
|
||||||
|
static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) {
|
||||||
|
NSDictionary *query = @{
|
||||||
|
(id)kSecClass: (id)kSecClassIdentity,
|
||||||
|
(id)kSecAttrLabel: [NSString stringWithUTF8String:label],
|
||||||
|
(id)kSecMatchLimit: (id)kSecMatchLimitOne,
|
||||||
|
(id)kSecReturnRef: @YES,
|
||||||
|
};
|
||||||
|
|
||||||
|
SecIdentityRef identity = NULL;
|
||||||
|
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity);
|
||||||
|
|
||||||
|
if (status != errSecSuccess || !identity) {
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d",
|
||||||
|
label, (int)status];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecKeyRef privateKey = NULL;
|
||||||
|
status = SecIdentityCopyPrivateKey(identity, &privateKey);
|
||||||
|
CFRelease(identity);
|
||||||
|
|
||||||
|
if (status != errSecSuccess || !privateKey) {
|
||||||
|
NSString *msg = [NSString stringWithFormat:
|
||||||
|
@"failed to get private key from CTK identity '%s': OSStatus %d",
|
||||||
|
label, (int)status];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_create_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *hash_out, int hash_out_len,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSString *labelStr = [NSString stringWithUTF8String:label];
|
||||||
|
|
||||||
|
// Shell out to sc_auth (which has SE entitlements) to create the key
|
||||||
|
NSTask *task = [[NSTask alloc] init];
|
||||||
|
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
task.arguments = @[
|
||||||
|
@"create-ctk-identity",
|
||||||
|
@"-k", @"p-256-ne",
|
||||||
|
@"-t", @"none",
|
||||||
|
@"-l", labelStr,
|
||||||
|
];
|
||||||
|
|
||||||
|
NSPipe *stderrPipe = [NSPipe pipe];
|
||||||
|
task.standardOutput = [NSPipe pipe];
|
||||||
|
task.standardError = stderrPipe;
|
||||||
|
|
||||||
|
NSError *nsError = nil;
|
||||||
|
if (![task launchAndReturnError:&nsError]) {
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||||
|
nsError.localizedDescription];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[task waitUntilExit];
|
||||||
|
|
||||||
|
if (task.terminationStatus != 0) {
|
||||||
|
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@",
|
||||||
|
stderrStr ?: @"unknown error"];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the public key from the created identity
|
||||||
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
|
if (!privateKey) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||||
|
CFRelease(privateKey);
|
||||||
|
|
||||||
|
if (!publicKey) {
|
||||||
|
snprintf_error(error_out, error_out_len, @"failed to get public key");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CFErrorRef cfError = NULL;
|
||||||
|
CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError);
|
||||||
|
CFRelease(publicKey);
|
||||||
|
|
||||||
|
if (!pubKeyData) {
|
||||||
|
NSError *err = (__bridge_transfer NSError *)cfError;
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@",
|
||||||
|
err.localizedDescription];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UInt8 *bytes = CFDataGetBytePtr(pubKeyData);
|
||||||
|
CFIndex length = CFDataGetLength(pubKeyData);
|
||||||
|
|
||||||
|
if (length > *pub_key_len) {
|
||||||
|
CFRelease(pubKeyData);
|
||||||
|
snprintf_error(error_out, error_out_len, @"public key buffer too small");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(pub_key_out, bytes, length);
|
||||||
|
*pub_key_len = (int)length;
|
||||||
|
CFRelease(pubKeyData);
|
||||||
|
|
||||||
|
// Get the identity hash by parsing sc_auth list output
|
||||||
|
hash_out[0] = '\0';
|
||||||
|
NSTask *listTask = [[NSTask alloc] init];
|
||||||
|
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
listTask.arguments = @[@"list-ctk-identities"];
|
||||||
|
|
||||||
|
NSPipe *listPipe = [NSPipe pipe];
|
||||||
|
listTask.standardOutput = listPipe;
|
||||||
|
listTask.standardError = [NSPipe pipe];
|
||||||
|
|
||||||
|
if ([listTask launchAndReturnError:&nsError]) {
|
||||||
|
[listTask waitUntilExit];
|
||||||
|
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||||
|
if ([line containsString:labelStr]) {
|
||||||
|
NSMutableArray *tokens = [NSMutableArray array];
|
||||||
|
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||||
|
if (part.length > 0) {
|
||||||
|
[tokens addObject:part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.count > 1) {
|
||||||
|
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_encrypt(const char *label,
|
||||||
|
const uint8_t *plaintext, int plaintext_len,
|
||||||
|
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
|
if (!privateKey) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||||
|
CFRelease(privateKey);
|
||||||
|
|
||||||
|
if (!publicKey) {
|
||||||
|
snprintf_error(error_out, error_out_len, @"failed to get public key for encryption");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len];
|
||||||
|
|
||||||
|
CFErrorRef cfError = NULL;
|
||||||
|
CFDataRef encrypted = SecKeyCreateEncryptedData(
|
||||||
|
publicKey,
|
||||||
|
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||||
|
(__bridge CFDataRef)plaintextData,
|
||||||
|
&cfError
|
||||||
|
);
|
||||||
|
CFRelease(publicKey);
|
||||||
|
|
||||||
|
if (!encrypted) {
|
||||||
|
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@",
|
||||||
|
nsError.localizedDescription];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UInt8 *encBytes = CFDataGetBytePtr(encrypted);
|
||||||
|
CFIndex encLength = CFDataGetLength(encrypted);
|
||||||
|
|
||||||
|
if (encLength > *ciphertext_len) {
|
||||||
|
CFRelease(encrypted);
|
||||||
|
snprintf_error(error_out, error_out_len, @"ciphertext buffer too small");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(ciphertext_out, encBytes, encLength);
|
||||||
|
*ciphertext_len = (int)encLength;
|
||||||
|
CFRelease(encrypted);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_decrypt(const char *label,
|
||||||
|
const uint8_t *ciphertext, int ciphertext_len,
|
||||||
|
uint8_t *plaintext_out, int *plaintext_len,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
|
if (!privateKey) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len];
|
||||||
|
|
||||||
|
CFErrorRef cfError = NULL;
|
||||||
|
CFDataRef decrypted = SecKeyCreateDecryptedData(
|
||||||
|
privateKey,
|
||||||
|
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||||
|
(__bridge CFDataRef)ciphertextData,
|
||||||
|
&cfError
|
||||||
|
);
|
||||||
|
CFRelease(privateKey);
|
||||||
|
|
||||||
|
if (!decrypted) {
|
||||||
|
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@",
|
||||||
|
nsError.localizedDescription];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UInt8 *decBytes = CFDataGetBytePtr(decrypted);
|
||||||
|
CFIndex decLength = CFDataGetLength(decrypted);
|
||||||
|
|
||||||
|
if (decLength > *plaintext_len) {
|
||||||
|
CFRelease(decrypted);
|
||||||
|
snprintf_error(error_out, error_out_len, @"plaintext buffer too small");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(plaintext_out, decBytes, decLength);
|
||||||
|
*plaintext_len = (int)decLength;
|
||||||
|
CFRelease(decrypted);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_delete_key(const char *hash,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSTask *task = [[NSTask alloc] init];
|
||||||
|
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
task.arguments = @[
|
||||||
|
@"delete-ctk-identity",
|
||||||
|
@"-h", [NSString stringWithUTF8String:hash],
|
||||||
|
];
|
||||||
|
|
||||||
|
NSPipe *stderrPipe = [NSPipe pipe];
|
||||||
|
task.standardOutput = [NSPipe pipe];
|
||||||
|
task.standardError = stderrPipe;
|
||||||
|
|
||||||
|
NSError *nsError = nil;
|
||||||
|
if (![task launchAndReturnError:&nsError]) {
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||||
|
nsError.localizedDescription];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[task waitUntilExit];
|
||||||
|
|
||||||
|
if (task.terminationStatus != 0) {
|
||||||
|
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@",
|
||||||
|
stderrStr ?: @"unknown error"];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package secret provides core types and constants for the secret application.
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
@@ -6,10 +7,13 @@ const (
|
|||||||
// AppID is the unique identifier for this application
|
// AppID is the unique identifier for this application
|
||||||
AppID = "berlin.sneak.pkg.secret"
|
AppID = "berlin.sneak.pkg.secret"
|
||||||
|
|
||||||
// Environment variable names
|
// EnvStateDir is the environment variable for specifying the state directory
|
||||||
EnvStateDir = "SB_SECRET_STATE_DIR"
|
EnvStateDir = "SB_SECRET_STATE_DIR"
|
||||||
|
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||||
|
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" //nolint:gosec // G101: This is an env var name, not a credential
|
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" //nolint:gosec // G101: This is an env var name, not a credential
|
||||||
|
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,33 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EncryptToRecipient encrypts data to a recipient using age
|
// EncryptToRecipient encrypts data to a recipient using age
|
||||||
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
// The data parameter should be a LockedBuffer for secure memory handling
|
||||||
Debug("EncryptToRecipient starting", "data_length", len(data))
|
func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
Debug("Creating age encryptor")
|
Debug("Creating age encryptor")
|
||||||
w, err := age.Encrypt(&buf, recipient)
|
w, err := age.Encrypt(&buf, recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to create encryptor", "error", err)
|
Debug("Failed to create encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Created age encryptor successfully")
|
Debug("Created age encryptor successfully")
|
||||||
|
|
||||||
Debug("Writing data to encryptor")
|
Debug("Writing data to encryptor")
|
||||||
if _, err := w.Write(data); err != nil {
|
if _, err := w.Write(data.Bytes()); err != nil {
|
||||||
Debug("Failed to write data to encryptor", "error", err)
|
Debug("Failed to write data to encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Wrote data to encryptor successfully")
|
Debug("Wrote data to encryptor successfully")
|
||||||
@@ -34,17 +42,19 @@ func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
|||||||
Debug("Closing encryptor")
|
Debug("Closing encryptor")
|
||||||
if err := w.Close(); err != nil {
|
if err := w.Close(); err != nil {
|
||||||
Debug("Failed to close encryptor", "error", err)
|
Debug("Failed to close encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Closed encryptor successfully")
|
Debug("Closed encryptor successfully")
|
||||||
|
|
||||||
result := buf.Bytes()
|
result := buf.Bytes()
|
||||||
Debug("EncryptToRecipient completed successfully", "result_length", len(result))
|
Debug("EncryptToRecipient completed successfully", "result_length", len(result))
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -55,12 +65,29 @@ 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)
|
||||||
|
|
||||||
|
// Zero out the original slice to prevent plaintext from lingering in unprotected memory
|
||||||
|
for i := range result {
|
||||||
|
result[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
|
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||||
recipient, err := age.NewScryptRecipient(passphrase)
|
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
|
if passphrase == nil {
|
||||||
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||||
|
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -69,8 +96,14 @@ func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||||
func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
|
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||||
identity, err := age.NewScryptIdentity(passphrase)
|
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||||
|
if passphrase == nil {
|
||||||
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||||
|
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -80,29 +113,42 @@ func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
|
|||||||
|
|
||||||
// ReadPassphrase reads a passphrase securely from the terminal without echoing
|
// ReadPassphrase reads a passphrase securely from the terminal without echoing
|
||||||
// This version is for unlocking and doesn't require confirmation
|
// This version is for unlocking and doesn't require confirmation
|
||||||
func ReadPassphrase(prompt string) (string, error) {
|
// Returns a LockedBuffer containing the passphrase for secure memory handling
|
||||||
|
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||||
// Check if stdin is a terminal
|
// Check if stdin is a terminal
|
||||||
if !term.IsTerminal(int(syscall.Stdin)) {
|
if !term.IsTerminal(syscall.Stdin) {
|
||||||
// Not a terminal - never read passphrases from piped input for security reasons
|
// Not a terminal - never read passphrases from piped input for security reasons
|
||||||
return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
|
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
|
||||||
|
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||||
|
"environment variable or run interactively")
|
||||||
}
|
}
|
||||||
|
|
||||||
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
||||||
if !term.IsTerminal(int(syscall.Stderr)) {
|
if !term.IsTerminal(syscall.Stderr) {
|
||||||
return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
|
return nil, fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
|
||||||
|
"(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||||
|
"environment variable")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both stdin and stderr are terminals - use secure password reading
|
// Both stdin and stderr are terminals - use secure password reading
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||||
passphrase, err := term.ReadPassword(int(syscall.Stdin))
|
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||||
|
|
||||||
if len(passphrase) == 0 {
|
if len(passphrase) == 0 {
|
||||||
return "", fmt.Errorf("passphrase cannot be empty")
|
return nil, fmt.Errorf("passphrase cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(passphrase), nil
|
// Create a secure buffer and copy the passphrase
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes(passphrase)
|
||||||
|
|
||||||
|
// Clear the original passphrase slice
|
||||||
|
for i := range passphrase {
|
||||||
|
passphrase[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
debugEnabled bool
|
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
|
||||||
debugLogger *slog.Logger
|
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -29,6 +29,7 @@ func InitDebugLogging() {
|
|||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
// Create a no-op logger that discards all output
|
// Create a no-op logger that discards all output
|
||||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ func InitDebugLogging() {
|
|||||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||||
|
|
||||||
// Check if STDERR is a TTY
|
// Check if STDERR is a TTY
|
||||||
isTTY := term.IsTerminal(int(syscall.Stderr))
|
isTTY := term.IsTerminal(syscall.Stderr)
|
||||||
|
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
if isTTY {
|
if isTTY {
|
||||||
@@ -57,6 +58,16 @@ func IsDebugEnabled() bool {
|
|||||||
return debugEnabled
|
return debugEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
|
||||||
|
func Warn(msg string, args ...any) {
|
||||||
|
output := fmt.Sprintf("WARNING: %s", msg)
|
||||||
|
for i := 0; i+1 < len(args); i += 2 {
|
||||||
|
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
|
||||||
|
}
|
||||||
|
output += "\n"
|
||||||
|
fmt.Fprint(os.Stderr, output)
|
||||||
|
}
|
||||||
|
|
||||||
// Debug logs a debug message with optional attributes
|
// Debug logs a debug message with optional attributes
|
||||||
func Debug(msg string, args ...any) {
|
func Debug(msg string, args ...any) {
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
@@ -113,6 +124,7 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
|||||||
}
|
}
|
||||||
first = false
|
first = false
|
||||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
output += "}\033[0m"
|
output += "}\033[0m"
|
||||||
@@ -120,6 +132,7 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
|||||||
|
|
||||||
output += "\n"
|
output += "\n"
|
||||||
_, err := h.output.Write([]byte(output))
|
_, err := h.output.Write([]byte(output))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestDebugLogging(t *testing.T) {
|
|||||||
|
|
||||||
// Override the debug logger for testing
|
// Override the debug logger for testing
|
||||||
oldLogger := debugLogger
|
oldLogger := debugLogger
|
||||||
if term.IsTerminal(int(syscall.Stderr)) {
|
if term.IsTerminal(syscall.Stderr) {
|
||||||
// TTY: use colorized handler with our buffer
|
// TTY: use colorized handler with our buffer
|
||||||
debugLogger = slog.New(newColorizedHandler(&buf))
|
debugLogger = slog.New(newColorizedHandler(&buf))
|
||||||
} else {
|
} else {
|
||||||
@@ -102,16 +102,16 @@ func TestDebugFunctions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test that debug functions don't panic and can be called
|
// Test that debug functions don't panic and can be called
|
||||||
t.Run("Debug", func(t *testing.T) {
|
t.Run("Debug", func(_ *testing.T) {
|
||||||
Debug("test debug message")
|
Debug("test debug message")
|
||||||
Debug("test with args", "key", "value", "number", 42)
|
Debug("test with args", "key", "value", "number", 42)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DebugF", func(t *testing.T) {
|
t.Run("DebugF", func(_ *testing.T) {
|
||||||
DebugF("formatted message: %s %d", "test", 123)
|
DebugF("formatted message: %s %d", "test", 123)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DebugWith", func(t *testing.T) {
|
t.Run("DebugWith", func(_ *testing.T) {
|
||||||
DebugWith("structured message",
|
DebugWith("structured message",
|
||||||
slog.String("string_key", "string_value"),
|
slog.String("string_key", "string_value"),
|
||||||
slog.Int("int_key", 42),
|
slog.Int("int_key", 42),
|
||||||
|
|||||||
82
internal/secret/derivation_index_test.go
Normal file
82
internal/secret/derivation_index_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
||||||
|
// using the same directory layout as vault.Vault.
|
||||||
|
type realVault struct {
|
||||||
|
name string
|
||||||
|
stateDir string
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *realVault) GetDirectory() (string, error) {
|
||||||
|
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||||
|
}
|
||||||
|
func (v *realVault) GetName() string { return v.name }
|
||||||
|
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||||
|
|
||||||
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
||||||
|
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
||||||
|
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
|
panic("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||||
|
// filesystem, identical to what vault.CreateVault produces.
|
||||||
|
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
|
||||||
|
|
||||||
|
metadata := VaultMetadata{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
DerivationIndex: derivationIndex,
|
||||||
|
}
|
||||||
|
metaBytes, err := json.Marshal(metadata)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||||
|
|
||||||
|
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||||
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// Derive expected keys at two different indices to prove they differ.
|
||||||
|
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, key0.String(), key5.String(),
|
||||||
|
"sanity check: different derivation indices must produce different keys")
|
||||||
|
|
||||||
|
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||||
|
|
||||||
|
t.Setenv(EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
|
result, err := getLongTermPrivateKey(fs, vault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer result.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||||
|
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
|
||||||
|
assert.NotEqual(t, key0.String(), string(result.Bytes()),
|
||||||
|
"getLongTermPrivateKey must not use hardcoded index 0")
|
||||||
|
}
|
||||||
@@ -28,16 +28,17 @@ func generateRandomString(length int, charset string) (string, error) {
|
|||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetermineStateDir determines the state directory based on environment variables and OS
|
// DetermineStateDir determines the state directory based on environment variables and OS.
|
||||||
func DetermineStateDir(customConfigDir string) string {
|
// It returns an error if no usable directory can be determined.
|
||||||
|
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||||
// Check for environment variable first
|
// Check for environment variable first
|
||||||
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
|
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
|
||||||
return envStateDir
|
return envStateDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom config dir if provided
|
// Use custom config dir if provided
|
||||||
if customConfigDir != "" {
|
if customConfigDir != "" {
|
||||||
return filepath.Join(customConfigDir, AppID)
|
return filepath.Join(customConfigDir, AppID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use os.UserConfigDir() which handles platform-specific directories:
|
// Use os.UserConfigDir() which handles platform-specific directories:
|
||||||
@@ -47,8 +48,16 @@ func DetermineStateDir(customConfigDir string) string {
|
|||||||
configDir, err := os.UserConfigDir()
|
configDir, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to a reasonable default if we can't determine user config dir
|
// Fallback to a reasonable default if we can't determine user config dir
|
||||||
homeDir, _ := os.UserHomeDir()
|
homeDir, homeErr := os.UserHomeDir()
|
||||||
return filepath.Join(homeDir, ".config", AppID)
|
if homeErr != nil {
|
||||||
|
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
||||||
}
|
}
|
||||||
return filepath.Join(configDir, AppID)
|
|
||||||
|
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
||||||
|
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
|
||||||
|
|
||||||
|
return fallbackDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(configDir, AppID), nil
|
||||||
}
|
}
|
||||||
|
|||||||
50
internal/secret/helpers_test.go
Normal file
50
internal/secret/helpers_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||||
|
// Clear all env vars that could provide a home/config directory.
|
||||||
|
// On Darwin, os.UserHomeDir may still succeed via the password
|
||||||
|
// database, so we also test via an explicit empty-customConfigDir
|
||||||
|
// path to exercise the fallback branch.
|
||||||
|
t.Setenv(EnvStateDir, "")
|
||||||
|
t.Setenv("HOME", "")
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
|
||||||
|
result, err := DetermineStateDir("")
|
||||||
|
// On systems where both lookups fail, we must get an error.
|
||||||
|
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
||||||
|
// result should still be valid (non-empty, not root-relative).
|
||||||
|
if err != nil {
|
||||||
|
// Good — the error case is handled.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == "/.config/"+AppID || result == "" {
|
||||||
|
t.Errorf("DetermineStateDir returned dangerous/empty path %q without error", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||||
|
t.Setenv(EnvStateDir, "/custom/state")
|
||||||
|
result, err := DetermineStateDir("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result != "/custom/state" {
|
||||||
|
t.Errorf("expected /custom/state, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
||||||
|
t.Setenv(EnvStateDir, "")
|
||||||
|
result, err := DetermineStateDir("/my/config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
expected := "/my/config/" + AppID
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,16 +9,24 @@ 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"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agePrivKeyPassphraseLength = 64
|
||||||
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
@@ -24,7 +35,7 @@ var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
// Keychain item name
|
// Keychain item name
|
||||||
KeychainItemName string `json:"keychain_item_name"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
||||||
@@ -36,9 +47,9 @@ type KeychainUnlocker struct {
|
|||||||
|
|
||||||
// KeychainData represents the data stored in the macOS keychain
|
// KeychainData represents the data stored in the macOS keychain
|
||||||
type KeychainData struct {
|
type KeychainData struct {
|
||||||
AgePublicKey string `json:"age_public_key"`
|
AgePublicKey string `json:"agePublicKey"`
|
||||||
AgePrivKeyPassphrase string `json:"age_priv_key_passphrase"`
|
AgePrivKeyPassphrase string `json:"agePrivKeyPassphrase"`
|
||||||
EncryptedLongtermKey string `json:"encrypted_longterm_key"`
|
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||||
@@ -52,6 +63,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
keychainItemName, err := k.GetKeychainItemName()
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +72,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +85,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
var keychainData KeychainData
|
var keychainData KeychainData
|
||||||
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
||||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +98,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,22 +109,30 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||||
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
||||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
// Create secure buffer for the keychain passphrase
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
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())
|
||||||
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", k.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +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
|
||||||
@@ -154,6 +181,7 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
keychainItemName, err := k.GetKeychainItemName()
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +189,7 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||||
if err := deleteFromKeychain(keychainItemName); err != nil {
|
if err := deleteFromKeychain(keychainItemName); err != nil {
|
||||||
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +197,12 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||||
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,82 +241,43 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
|||||||
|
|
||||||
// Format: secret-<vault>-<hostname>-<date>
|
// Format: secret-<vault>-<hostname>-<date>
|
||||||
enrollmentDate := time.Now().Format("2006-01-02")
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
||||||
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||||
// Check if we're on macOS
|
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
if err := checkMacOSAvailable(); err != nil {
|
// Check if mnemonic is available in environment variable
|
||||||
return nil, err
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
}
|
if envMnemonic != "" {
|
||||||
|
// Read vault metadata to get the correct derivation index
|
||||||
// Get current vault using the GetCurrentVault function from the same package
|
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the keychain item name
|
|
||||||
keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unlocker directory using the keychain item name as the directory name
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
var metadata VaultMetadata
|
||||||
agePrivKeyPassphrase, err := generateRandomPassphrase(64)
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Store age recipient as plaintext
|
// Use mnemonic with the vault's actual derivation index
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
||||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
|
||||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
|
||||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Get or derive the long-term private key
|
|
||||||
var ltPrivKeyData []byte
|
|
||||||
|
|
||||||
// Check if mnemonic is available in environment variable
|
|
||||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
|
||||||
// Use mnemonic directly to derive long-term key
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
ltPrivKeyData = []byte(ltIdentity.String())
|
|
||||||
} else {
|
// Return the private key in a secure buffer
|
||||||
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get the vault to access current unlocker
|
// Get the vault to access current unlocker
|
||||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -327,12 +319,90 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 buffer
|
||||||
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||||
|
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||||
|
// Check if we're on macOS
|
||||||
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current vault using the GetCurrentVault function from the same package
|
||||||
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the keychain item name
|
||||||
|
keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unlocker directory using the keychain item name as the directory name
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||||
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||||
|
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Store age recipient as plaintext
|
||||||
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
||||||
|
// Create secure buffers for both the private key and passphrase
|
||||||
|
agePrivKeyStr := ageIdentity.String()
|
||||||
|
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
||||||
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
|
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Get or derive the long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 6: Encrypt long-term private key to the new age unlocker
|
// Step 6: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -357,8 +427,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +451,9 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
if err := afero.WriteFile(fs,
|
||||||
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
|
metadataBytes, FilePerms); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,12 +464,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,59 +486,91 @@ 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 // Input validated by validateKeychainItemName
|
|
||||||
"-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 // Input validated by validateKeychainItemName
|
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
|
||||||
|
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
||||||
|
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
||||||
func deleteFromKeychain(itemName string) error {
|
func deleteFromKeychain(itemName string) error {
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
|
item := keychain.NewItem()
|
||||||
"-a", itemName,
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
"-s", itemName)
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
|
// If the item doesn't exist, that's not an error - the goal is to ensure
|
||||||
|
// the item is gone, and it already being gone satisfies that goal.
|
||||||
|
// This is important for cleaning up unlocker directories when the keychain
|
||||||
|
// item has already been removed (e.g., manually by user, or synced vault
|
||||||
|
// from a different machine).
|
||||||
|
if err == keychain.ErrorItemNotFound {
|
||||||
|
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
internal/secret/keychainunlocker_stub.go
Normal file
82
internal/secret/keychainunlocker_stub.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||||
|
|
||||||
|
// GetIdentity returns an error on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
|
return nil, errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType returns the unlocker type
|
||||||
|
func (k *KeychainUnlocker) GetType() string {
|
||||||
|
return "keychain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata returns the unlocker metadata
|
||||||
|
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
||||||
|
return k.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirectory returns the unlocker directory
|
||||||
|
func (k *KeychainUnlocker) GetDirectory() string {
|
||||||
|
return k.Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the unlocker ID
|
||||||
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
|
return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||||
|
return "", errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove returns an error on non-Darwin platforms
|
||||||
|
func (k *KeychainUnlocker) Remove() error {
|
||||||
|
return errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin platforms.
|
||||||
|
// The returned instance's methods that require macOS functionality will return errors.
|
||||||
|
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||||
|
return &KeychainUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||||
|
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||||
|
return nil, errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
||||||
|
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
|
return nil, errKeychainNotSupported
|
||||||
|
}
|
||||||
184
internal/secret/keychainunlocker_test.go
Normal file
184
internal/secret/keychainunlocker_test.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure item doesn't exist
|
||||||
|
testItemName := "test-nonexistent-keychain-item-12345"
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
|
||||||
|
// Deleting a non-existent item should NOT return an error
|
||||||
|
// This is important for cleaning up unlocker directories when the keychain item
|
||||||
|
// has already been removed (e.g., manually by user, or on a different machine)
|
||||||
|
err := deleteFromKeychain(testItemName)
|
||||||
|
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error")
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
type VaultMetadata struct {
|
type VaultMetadata struct {
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
DerivationIndex uint32 `json:"derivation_index"`
|
DerivationIndex uint32 `json:"derivationIndex"`
|
||||||
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the actual long-term public key
|
// Double SHA256 hash of the actual long-term public key
|
||||||
MnemonicFamilyHash string `json:"mnemonic_family_hash,omitempty"` // Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
|
PublicKeyHash string `json:"publicKeyHash,omitempty"`
|
||||||
|
// Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
|
||||||
|
MnemonicFamilyHash string `json:"mnemonicFamilyHash,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockerMetadata contains information about an unlocker
|
// UnlockerMetadata contains information about an unlocker
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,8 +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))
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, testPassphrase)
|
defer privKeyBuffer.Destroy()
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -110,8 +114,9 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
t.Fatalf("Failed to parse recipient: %v", err)
|
t.Fatalf("Failed to parse recipient: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyData := []byte(ltIdentity.String())
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, recipient)
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +16,40 @@ type PassphraseUnlocker struct {
|
|||||||
Directory string
|
Directory string
|
||||||
Metadata UnlockerMetadata
|
Metadata UnlockerMetadata
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
Passphrase string
|
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPassphrase retrieves the passphrase from memory, environment, or user input
|
||||||
|
// Returns a LockedBuffer for secure memory handling
|
||||||
|
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||||
|
// First check if we already have the passphrase
|
||||||
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
|
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||||
|
// Return a copy of the passphrase buffer
|
||||||
|
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("No passphrase in memory, checking environment")
|
||||||
|
// Check environment variable for passphrase
|
||||||
|
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||||
|
if passphraseStr != "" {
|
||||||
|
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||||
|
// Convert to secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("No passphrase in environment, prompting user")
|
||||||
|
// Prompt for passphrase
|
||||||
|
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
||||||
@@ -25,27 +59,11 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
slog.String("unlocker_type", p.GetType()),
|
slog.String("unlocker_type", p.GetType()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// First check if we already have the passphrase
|
passphraseBuffer, err := p.getPassphrase()
|
||||||
passphraseStr := p.Passphrase
|
|
||||||
if passphraseStr == "" {
|
|
||||||
Debug("No passphrase in memory, checking environment")
|
|
||||||
// Check environment variable for passphrase
|
|
||||||
passphraseStr = os.Getenv(EnvUnlockPassphrase)
|
|
||||||
if passphraseStr == "" {
|
|
||||||
Debug("No passphrase in environment, prompting user")
|
|
||||||
// Prompt for passphrase
|
|
||||||
var err error
|
|
||||||
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
return nil, err
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
|
||||||
}
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Read encrypted private key of unlocker
|
// Read encrypted private key of unlocker
|
||||||
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
|
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
|
||||||
@@ -54,6 +72,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,22 +84,26 @@ 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, passphraseStr)
|
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())
|
||||||
identity, err := age.ParseX25519Identity(string(privKeyData))
|
|
||||||
|
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())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +134,23 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
|||||||
func (p *PassphraseUnlocker) GetID() string {
|
func (p *PassphraseUnlocker) GetID() string {
|
||||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||||
createdAt := p.Metadata.CreatedAt
|
createdAt := p.Metadata.CreatedAt
|
||||||
|
|
||||||
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||||
func (p *PassphraseUnlocker) Remove() error {
|
func (p *PassphraseUnlocker) Remove() error {
|
||||||
|
// Clean up the passphrase from memory if it exists
|
||||||
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
|
p.Passphrase.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// For passphrase unlockers, we just need to remove the directory
|
// For passphrase unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// No external resources (like keychain items) to clean up
|
||||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +164,12 @@ func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetad
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
|
func CreatePassphraseUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
stateDir string,
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) (*PassphraseUnlocker, error) {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"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"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||||
func setupNonInteractiveGPG(t *testing.T, tempDir, passphrase, gnupgHomeDir string) {
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||||
// Create GPG config file for non-interactive operation
|
// Create GPG config file for non-interactive operation
|
||||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||||
gpgConfContent := `batch
|
gpgConfContent := `batch
|
||||||
@@ -44,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",
|
||||||
@@ -59,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())
|
||||||
@@ -68,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",
|
||||||
@@ -87,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
|
||||||
@@ -270,7 +275,9 @@ Passphrase: ` + testPassphrase + `
|
|||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Create a passphrase unlocker first (to have current unlocker)
|
// Create a passphrase unlocker first (to have current unlocker)
|
||||||
passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
}
|
}
|
||||||
@@ -357,9 +364,9 @@ Passphrase: ` + testPassphrase + `
|
|||||||
var metadata struct {
|
var metadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Flags []string `json:"flags"`
|
Flags []string `json:"flags"`
|
||||||
GPGKeyID string `json:"gpg_key_id"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
@@ -396,7 +403,7 @@ Passphrase: ` + testPassphrase + `
|
|||||||
// Create PGP metadata with GPG key ID
|
// Create PGP metadata with GPG key ID
|
||||||
type PGPUnlockerMetadata struct {
|
type PGPUnlockerMetadata struct {
|
||||||
secret.UnlockerMetadata
|
secret.UnlockerMetadata
|
||||||
GPGKeyID string `json:"gpg_key_id"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
@@ -441,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
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
|
||||||
|
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||||
|
|
||||||
// gpgKeyIDRegex validates GPG key IDs
|
// gpgKeyIDRegex validates GPG key IDs
|
||||||
// Allows either:
|
// Allows either:
|
||||||
@@ -44,7 +46,7 @@ var (
|
|||||||
type PGPUnlockerMetadata struct {
|
type PGPUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
// GPG key ID used for encryption
|
// GPG key ID used for encryption
|
||||||
GPGKeyID string `json:"gpg_key_id"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PGPUnlocker represents a PGP-protected unlocker
|
// PGPUnlocker represents a PGP-protected unlocker
|
||||||
@@ -68,6 +70,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,22 +81,25 @@ 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())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,14 +128,15 @@ 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
|
||||||
// We cannot continue with a fallback ID as that would mask data corruption
|
// We cannot continue with a fallback ID as that would mask data corruption
|
||||||
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
|
||||||
@@ -139,6 +146,7 @@ func (p *PGPUnlocker) Remove() error {
|
|||||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +185,7 @@ func generatePGPUnlockerName() (string, error) {
|
|||||||
|
|
||||||
// Format: hostname-pgp-YYYY-MM-DD
|
// Format: hostname-pgp-YYYY-MM-DD
|
||||||
enrollmentDate := time.Now().Format("2006-01-02")
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,56 +233,11 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Get or derive the long-term private key
|
// Step 3: Get or derive the long-term private key
|
||||||
var ltPrivKeyData []byte
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
|
|
||||||
// Check if mnemonic is available in environment variable
|
|
||||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
|
||||||
// Use mnemonic directly to derive long-term key
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
ltPrivKeyData = []byte(ltIdentity.String())
|
|
||||||
} else {
|
|
||||||
// Get the vault to access current unlocker
|
|
||||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current unlocker identity
|
|
||||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get encrypted long-term key from current unlocker, handling different types
|
|
||||||
var encryptedLtPrivKey []byte
|
|
||||||
switch currentUnlocker := currentUnlocker.(type) {
|
|
||||||
case *PassphraseUnlocker:
|
|
||||||
// Read the encrypted long-term private key from passphrase unlocker
|
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
case *PGPUnlocker:
|
|
||||||
// Read the encrypted long-term private key from PGP unlocker
|
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Decrypt long-term private key using current unlocker
|
|
||||||
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||||
@@ -288,8 +252,11 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Encrypt age private key to the GPG key ID
|
// Step 8: Encrypt age private key to the GPG key ID
|
||||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
// Use memguard to protect the private key in memory
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBytes, gpgKeyID)
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -300,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)
|
||||||
}
|
}
|
||||||
@@ -320,7 +287,9 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
if err := afero.WriteFile(fs,
|
||||||
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
|
metadataBytes, FilePerms); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,14 +313,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use GPG to get the full fingerprint for the key
|
// Use GPG to get the full fingerprint for the key
|
||||||
cmd := exec.Command("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID)
|
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||||
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||||
|
)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
@@ -377,17 +348,23 @@ func checkGPGAvailable() error {
|
|||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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( // #nosec G204 -- keyID validated
|
||||||
cmd.Stdin = strings.NewReader(string(data))
|
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||||
|
)
|
||||||
|
cmd.Stdin = strings.NewReader(data.String())
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -398,7 +375,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))
|
||||||
|
|
||||||
@@ -407,5 +384,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
|
|
||||||
"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/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VaultInterface defines the interface that vault implementations must satisfy
|
// VaultInterface defines the interface that vault implementations must satisfy
|
||||||
type VaultInterface interface {
|
type VaultInterface interface {
|
||||||
GetDirectory() (string, error)
|
GetDirectory() (string, error)
|
||||||
AddSecret(name string, value []byte, force bool) error
|
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
|
||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error)
|
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret represents a secret in a vault
|
// Secret represents a secret in a vault
|
||||||
@@ -61,28 +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),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := s.vault.AddSecret(s.Name, value, 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()),
|
||||||
@@ -92,10 +73,12 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
exists, err := s.Exists()
|
exists, err := s.Exists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||||
|
|
||||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +88,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +103,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
vaultDir, err := s.vault.GetDirectory()
|
vaultDir, err := s.vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +112,14 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata VaultMetadata
|
var metadata VaultMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +133,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
|
Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +148,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
// Use the provided unlocker to get the vault's long-term private key
|
// Use the provided unlocker to get the vault's long-term private key
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +162,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
unlockIdentity, err := unlocker.GetIdentity()
|
unlockIdentity, err := unlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,22 +173,26 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,18 +214,21 @@ func (s *Secret) LoadMetadata() error {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata returns the secret metadata (deprecated)
|
// GetMetadata returns the secret metadata (deprecated)
|
||||||
func (s *Secret) GetMetadata() Metadata {
|
func (s *Secret) GetMetadata() Metadata {
|
||||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||||
|
|
||||||
return s.Metadata
|
return s.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEncryptedData is deprecated - data is now stored in versions
|
// GetEncryptedData is deprecated - data is now stored in versions
|
||||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||||
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +243,13 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
||||||
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
Debug("Secret directory does not exist", "secret_dir", s.Directory)
|
Debug("Secret directory does not exist", "secret_dir", s.Directory)
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +257,7 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("No current version found", "error", err, "secret_name", s.Name)
|
Debug("No current version found", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +278,14 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
|||||||
if getCurrentVaultFunc == nil {
|
if getCurrentVaultFunc == nil {
|
||||||
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
return getCurrentVaultFunc(fs, stateDir)
|
return getCurrentVaultFunc(fs, stateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCurrentVaultFunc is a function variable that will be set by the vault package
|
// getCurrentVaultFunc is a function variable that will be set by the vault package
|
||||||
// to implement the actual GetCurrentVault functionality
|
// to implement the actual GetCurrentVault functionality
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // Required to break import cycle
|
||||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||||
|
|
||||||
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"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/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -25,7 +26,7 @@ func (m *MockVault) GetDirectory() (string, error) {
|
|||||||
return m.directory, nil
|
return m.directory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) AddSecret(name string, value []byte, _ bool) error {
|
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
|
||||||
// Create secret directory with proper storage name conversion
|
// Create secret directory with proper storage name conversion
|
||||||
storageName := strings.ReplaceAll(name, "/", "%")
|
storageName := strings.ReplaceAll(name, "/", "%")
|
||||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||||
@@ -74,7 +75,7 @@ func (m *MockVault) AddSecret(name string, value []byte, _ bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt value to version's public key
|
// Encrypt value to version's public key (value is already a LockedBuffer)
|
||||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -87,7 +88,9 @@ func (m *MockVault) AddSecret(name string, value []byte, _ bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt version private key to long-term public key
|
// Encrypt version private key to long-term public key
|
||||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionIdentity.String()), ltIdentity.Recipient())
|
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||||
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -98,10 +101,9 @@ func (m *MockVault) AddSecret(name string, value []byte, _ bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create current symlink pointing to the version
|
// Create current file pointing to the version (just the version name)
|
||||||
currentLink := filepath.Join(secretDir, "current")
|
currentLink := filepath.Join(secretDir, "current")
|
||||||
// For MemMapFs, write a file with the target path
|
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil {
|
||||||
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0o600); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) CreatePassphraseUnlocker(_ string) (*PassphraseUnlocker, error) {
|
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +181,13 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
secretName := "test-secret"
|
secretName := "test-secret"
|
||||||
secretValue := []byte("this is a test secret value")
|
secretValue := []byte("this is a test secret value")
|
||||||
|
|
||||||
|
// Create a secure buffer for the test value
|
||||||
|
valueBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Test AddSecret
|
// Test AddSecret
|
||||||
t.Run("AddSecret", func(t *testing.T) {
|
t.Run("AddSecret", func(t *testing.T) {
|
||||||
err := vault.AddSecret(secretName, secretValue, false)
|
err := vault.AddSecret(secretName, valueBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddSecret failed: %v", err)
|
t.Fatalf("AddSecret failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -251,9 +257,10 @@ func isValidSecretName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash
|
// Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
|
||||||
for _, char := range name {
|
for _, char := range name {
|
||||||
if (char < 'a' || char > 'z') && // lowercase letters
|
if (char < 'a' || char > 'z') && // lowercase letters
|
||||||
|
(char < 'A' || char > 'Z') && // uppercase letters
|
||||||
(char < '0' || char > '9') && // numbers
|
(char < '0' || char > '9') && // numbers
|
||||||
char != '-' && // dash
|
char != '-' && // dash
|
||||||
char != '.' && // dot
|
char != '.' && // dot
|
||||||
@@ -262,6 +269,7 @@ func isValidSecretName(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +284,9 @@ func TestSecretNameValidation(t *testing.T) {
|
|||||||
{"valid/path/name", true},
|
{"valid/path/name", true},
|
||||||
{"123valid", true},
|
{"123valid", true},
|
||||||
{"", false},
|
{"", false},
|
||||||
{"Invalid-Name", false}, // uppercase not allowed
|
{"Valid-Upper-Name", true}, // uppercase allowed
|
||||||
|
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
|
||||||
|
{"MixedCase/Path/Name", true}, // mixed case with path
|
||||||
{"invalid name", false}, // space not allowed
|
{"invalid name", false}, // space not allowed
|
||||||
{"invalid@name", false}, // @ not allowed
|
{"invalid@name", false}, // @ not allowed
|
||||||
}
|
}
|
||||||
|
|||||||
385
internal/secret/seunlocker_darwin.go
Normal file
385
internal/secret/seunlocker_darwin.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
|
||||||
|
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
|
||||||
|
|
||||||
|
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
|
||||||
|
seUnlockerType = "secure-enclave"
|
||||||
|
|
||||||
|
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
|
||||||
|
seLongtermFilename = "longterm.age.se"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||||
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
|
UnlockerMetadata
|
||||||
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
|
||||||
|
type SecureEnclaveUnlocker struct {
|
||||||
|
Directory string
|
||||||
|
Metadata UnlockerMetadata
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
||||||
|
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
|
DebugWith("Getting SE unlocker identity",
|
||||||
|
slog.String("unlocker_id", s.GetID()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get SE key label from metadata
|
||||||
|
seKeyLabel, _, err := s.getSEKeyInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get SE key info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ECIES-encrypted long-term private key from disk
|
||||||
|
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||||
|
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to read SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Read SE-encrypted long-term key",
|
||||||
|
slog.Int("encrypted_length", len(encryptedData)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
|
||||||
|
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to decrypt long-term key with SE: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the decrypted long-term private key
|
||||||
|
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
|
||||||
|
|
||||||
|
// Clear sensitive data immediately
|
||||||
|
for i := range decryptedData {
|
||||||
|
decryptedData[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to parse long-term private key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Successfully decrypted long-term key via SE",
|
||||||
|
slog.String("unlocker_id", s.GetID()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType implements Unlocker interface.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||||
|
return seUnlockerType
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata implements Unlocker interface.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||||
|
return s.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirectory implements Unlocker interface.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||||
|
return s.Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID implements Unlocker interface.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := s.Metadata.CreatedAt
|
||||||
|
timestamp := createdAt.Format("2006-01-02.15.04")
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove implements Unlocker interface.
|
||||||
|
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||||
|
_, seKeyHash, err := s.getSEKeyInfo()
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to get SE key info during removal", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get SE key info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seKeyHash != "" {
|
||||||
|
Debug("Deleting SE key", "hash", seKeyHash)
|
||||||
|
if err := macse.DeleteKey(seKeyHash); err != nil {
|
||||||
|
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||||
|
if err := s.fs.RemoveAll(s.Directory); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||||
|
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
|
||||||
|
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||||
|
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seMetadata SecureEnclaveUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||||
|
func NewSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
directory string,
|
||||||
|
metadata UnlockerMetadata,
|
||||||
|
) *SecureEnclaveUnlocker {
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||||
|
func generateSEKeyLabel(vaultName string) (string, error) {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollmentDate := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s.%s-%s-%s",
|
||||||
|
seKeyLabelPrefix,
|
||||||
|
vaultName,
|
||||||
|
hostname,
|
||||||
|
enrollmentDate,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||||
|
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||||
|
// using ECIES. No intermediate age keypair is used.
|
||||||
|
func CreateSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
stateDir string,
|
||||||
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate SE key label
|
||||||
|
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
|
||||||
|
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||||
|
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||||
|
|
||||||
|
// Step 2: Get the vault's long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to get long-term private key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
|
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
||||||
|
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to encrypt long-term key with SE: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Create unlocker directory and write files
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to create unlocker directory: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SE-encrypted long-term key
|
||||||
|
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
|
||||||
|
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to write SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
|
Type: seUnlockerType,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Flags: []string{seUnlockerType, "macos"},
|
||||||
|
},
|
||||||
|
SEKeyLabel: seKeyLabel,
|
||||||
|
SEKeyHash: seKeyHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: unlockerDir,
|
||||||
|
Metadata: seMetadata.UnlockerMetadata,
|
||||||
|
fs: fs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLongTermKeyForSE retrieves the vault's long-term private key
|
||||||
|
// either from the mnemonic env var or by unlocking via the current unlocker.
|
||||||
|
func getLongTermKeyForSE(
|
||||||
|
fs afero.Fs,
|
||||||
|
vault VaultInterface,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
|
if envMnemonic != "" {
|
||||||
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(
|
||||||
|
envMnemonic,
|
||||||
|
metadata.DerivationIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to get current unlocker identity: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All unlocker types store longterm.age in their directory
|
||||||
|
longtermPath := filepath.Join(
|
||||||
|
currentUnlocker.GetDirectory(),
|
||||||
|
"longterm.age",
|
||||||
|
)
|
||||||
|
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to read encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||||
|
encryptedLtKey,
|
||||||
|
currentIdentity,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ltPrivKeyBuffer, nil
|
||||||
|
}
|
||||||
84
internal/secret/seunlocker_stub.go
Normal file
84
internal/secret/seunlocker_stub.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errSENotSupported = fmt.Errorf(
|
||||||
|
"secure enclave unlockers are only supported on macOS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||||
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
|
UnlockerMetadata
|
||||||
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecureEnclaveUnlocker is a stub for non-Darwin platforms.
|
||||||
|
type SecureEnclaveUnlocker struct {
|
||||||
|
Directory string
|
||||||
|
Metadata UnlockerMetadata
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentity returns an error on non-Darwin platforms.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
|
return nil, errSENotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType returns the unlocker type.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||||
|
return "secure-enclave"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata returns the unlocker metadata.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||||
|
return s.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirectory returns the unlocker directory.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||||
|
return s.Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the unlocker ID.
|
||||||
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s-secure-enclave",
|
||||||
|
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove returns an error on non-Darwin platforms.
|
||||||
|
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||||
|
return errSENotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms.
|
||||||
|
// The returned instance's methods that require macOS functionality will return errors.
|
||||||
|
func NewSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
directory string,
|
||||||
|
metadata UnlockerMetadata,
|
||||||
|
) *SecureEnclaveUnlocker {
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||||
|
func CreateSecureEnclaveUnlocker(
|
||||||
|
_ afero.Fs,
|
||||||
|
_ string,
|
||||||
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
|
return nil, errSENotSupported
|
||||||
|
}
|
||||||
90
internal/secret/seunlocker_stub_test.go
Normal file
90
internal/secret/seunlocker_stub_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
dir := "/tmp/test-se-unlocker"
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
|
Flags: []string{"secure-enclave", "macos"},
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
|
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||||
|
|
||||||
|
// Test GetType returns correct type
|
||||||
|
assert.Equal(t, "secure-enclave", unlocker.GetType())
|
||||||
|
|
||||||
|
// Test GetMetadata returns the metadata we passed in
|
||||||
|
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||||
|
|
||||||
|
// Test GetDirectory returns the directory we passed in
|
||||||
|
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||||
|
|
||||||
|
// Test GetID returns a formatted string with the creation timestamp
|
||||||
|
expectedID := "2026-01-15.10.30-secure-enclave"
|
||||||
|
assert.Equal(t, expectedID, unlocker.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
|
identity, err := unlocker.GetIdentity()
|
||||||
|
assert.Nil(t, identity)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
|
err := unlocker.Remove()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||||
|
assert.Nil(t, unlocker)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
|
// Verify the stub implements the Unlocker interface
|
||||||
|
var _ Unlocker = unlocker
|
||||||
|
}
|
||||||
101
internal/secret/seunlocker_test.go
Normal file
101
internal/secret/seunlocker_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
dir := "/tmp/test-se-unlocker"
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
|
Flags: []string{"secure-enclave", "macos"},
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
|
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||||
|
|
||||||
|
// Test GetType returns correct type
|
||||||
|
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||||
|
|
||||||
|
// Test GetMetadata returns the metadata we passed in
|
||||||
|
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||||
|
|
||||||
|
// Test GetDirectory returns the directory we passed in
|
||||||
|
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
|
// Verify the darwin implementation implements the Unlocker interface
|
||||||
|
var _ Unlocker = unlocker
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
id := unlocker.GetID()
|
||||||
|
|
||||||
|
// ID should contain the timestamp and "secure-enclave" type
|
||||||
|
assert.Contains(t, id, "2026-03-10.14.30")
|
||||||
|
assert.Contains(t, id, seUnlockerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||||
|
label, err := generateSEKeyLabel("test-vault")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Label should contain the prefix and vault name
|
||||||
|
assert.Contains(t, label, seKeyLabelPrefix)
|
||||||
|
assert.Contains(t, label, "test-vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
dir := "/tmp/test-se-unlocker-missing"
|
||||||
|
|
||||||
|
// Create unlocker directory with metadata but no encrypted key file
|
||||||
|
require.NoError(t, fs.MkdirAll(dir, DirPerms))
|
||||||
|
|
||||||
|
metadataJSON := `{
|
||||||
|
"type": "secure-enclave",
|
||||||
|
"createdAt": "2026-01-15T10:30:00Z",
|
||||||
|
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||||
|
"seKeyHash": "abc123"
|
||||||
|
}`
|
||||||
|
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
|
||||||
|
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: "secure-enclave",
|
||||||
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
|
|
||||||
|
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||||
|
identity, err := unlocker.GetIdentity()
|
||||||
|
assert.Nil(t, identity)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
|
||||||
|
}
|
||||||
@@ -4,17 +4,22 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
versionNameParts = 2
|
||||||
|
maxVersionsPerDay = 999
|
||||||
|
)
|
||||||
|
|
||||||
// VersionMetadata contains information about a secret version
|
// VersionMetadata contains information about a secret version
|
||||||
type VersionMetadata struct {
|
type VersionMetadata struct {
|
||||||
ID string `json:"id"` // ULID
|
ID string `json:"id"` // ULID
|
||||||
@@ -51,6 +56,7 @@ func NewVersion(vault VaultInterface, secretName string, version string) *Versio
|
|||||||
)
|
)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
return &Version{
|
return &Version{
|
||||||
SecretName: secretName,
|
SecretName: secretName,
|
||||||
Version: version,
|
Version: version,
|
||||||
@@ -83,23 +89,32 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
prefix := today + "."
|
prefix := today + "."
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
// Skip non-directories and those without correct prefix
|
||||||
|
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Extract serial number
|
// Extract serial number
|
||||||
parts := strings.Split(entry.Name(), ".")
|
parts := strings.Split(entry.Name(), ".")
|
||||||
if len(parts) == 2 {
|
if len(parts) != versionNameParts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var serial int
|
var serial int
|
||||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
|
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||||
|
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if serial > maxSerial {
|
if serial > maxSerial {
|
||||||
maxSerial = serial
|
maxSerial = serial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new version name
|
// Generate new version name
|
||||||
newSerial := maxSerial + 1
|
newSerial := maxSerial + 1
|
||||||
if newSerial > 999 {
|
if newSerial > maxVersionsPerDay {
|
||||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +122,15 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save saves the version metadata and value
|
// Save saves the version metadata and value
|
||||||
func (sv *Version) Save(value []byte) error {
|
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||||
|
if value == nil {
|
||||||
|
return fmt.Errorf("value buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
DebugWith("Saving secret version",
|
DebugWith("Saving secret version",
|
||||||
slog.String("secret_name", sv.SecretName),
|
slog.String("secret_name", sv.SecretName),
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
slog.Int("value_length", len(value)),
|
slog.Int("value_length", value.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
fs := sv.vault.GetFilesystem()
|
fs := sv.vault.GetFilesystem()
|
||||||
@@ -119,6 +138,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
// Create version directory
|
// Create version directory
|
||||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create version directory: %w", err)
|
return fmt.Errorf("failed to create version directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +147,14 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
versionIdentity, err := age.GenerateX25519Identity()
|
versionIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionPublicKey := versionIdentity.Recipient().String()
|
versionPublicKey := versionIdentity.Recipient().String()
|
||||||
versionPrivateKey := versionIdentity.String()
|
// Store private key in memguard buffer immediately
|
||||||
|
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||||
|
defer versionPrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Generated version keypair",
|
DebugWith("Generated version keypair",
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
@@ -143,6 +166,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
Debug("Writing version public key", "path", pubKeyPath)
|
Debug("Writing version public key", "path", pubKeyPath)
|
||||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write version public key: %w", err)
|
return fmt.Errorf("failed to write version public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +175,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +184,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
Debug("Writing encrypted version value", "path", valuePath)
|
Debug("Writing encrypted version value", "path", valuePath)
|
||||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +196,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +204,16 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse long-term public key", "error", err)
|
Debug("Failed to parse long-term public key", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Encrypt the version's private key to the long-term public key
|
// Step 6: Encrypt the version's private key to the long-term public key
|
||||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionPrivateKey), ltRecipient)
|
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +222,7 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
||||||
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,13 +231,18 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to marshal version metadata", "error", err)
|
Debug("Failed to marshal version metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt metadata to the version's public key
|
// Encrypt metadata to the version's public key
|
||||||
encryptedMetadata, err := EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||||
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +250,12 @@ func (sv *Version) Save(value []byte) error {
|
|||||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +273,24 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,30 +299,35 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
sv.Metadata = metadata
|
sv.Metadata = metadata
|
||||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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),
|
||||||
@@ -302,23 +348,27 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse version private key: %w", err)
|
return nil, fmt.Errorf("failed to parse version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,23 +378,26 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
|
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -379,58 +432,32 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
return versions, nil
|
return versions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVersion returns the version that the "current" symlink points to
|
// GetCurrentVersion returns the version that the "current" file points to
|
||||||
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
// Try to read as a real symlink first
|
|
||||||
if _, ok := fs.(*afero.OsFs); ok {
|
|
||||||
target, err := os.Readlink(currentPath)
|
|
||||||
if err == nil {
|
|
||||||
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
|
|
||||||
parts := strings.Split(target, "/")
|
|
||||||
if len(parts) >= 2 && parts[0] == "versions" {
|
|
||||||
return parts[1], nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to reading as a file (for MemMapFs testing)
|
|
||||||
fileData, err := afero.ReadFile(fs, currentPath)
|
fileData, err := afero.ReadFile(fs, currentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read current version symlink: %w", err)
|
return "", fmt.Errorf("failed to read current version file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := strings.TrimSpace(string(fileData))
|
version := strings.TrimSpace(string(fileData))
|
||||||
|
|
||||||
// Extract version from path
|
return version, nil
|
||||||
parts := strings.Split(target, "/")
|
|
||||||
if len(parts) >= 2 && parts[0] == "versions" {
|
|
||||||
return parts[1], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
// SetCurrentVersion updates the "current" file to point to a specific version
|
||||||
}
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
|
|
||||||
// SetCurrentVersion updates the "current" symlink to point to a specific version
|
|
||||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
targetPath := filepath.Join("versions", version)
|
|
||||||
|
|
||||||
// Remove existing symlink if it exists
|
// Remove existing file if it exists
|
||||||
_ = fs.Remove(currentPath)
|
_ = fs.Remove(currentPath)
|
||||||
|
|
||||||
// Try to create a real symlink first (works on Unix systems)
|
// Write just the version name to the file
|
||||||
if _, ok := fs.(*afero.OsFs); ok {
|
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil {
|
||||||
if err := os.Symlink(targetPath, currentPath); err == nil {
|
return fmt.Errorf("failed to create current version file: %w", err)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to creating a file with the target path (for MemMapFs testing)
|
|
||||||
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
|
|
||||||
return fmt.Errorf("failed to create current version symlink: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -58,7 +59,7 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
|||||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
|
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||||
return fmt.Errorf("not implemented in mock")
|
return fmt.Errorf("not implemented in mock")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
|||||||
return nil, fmt.Errorf("not implemented in mock")
|
return nil, fmt.Errorf("not implemented in mock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
return nil, fmt.Errorf("not implemented in mock")
|
return nil, fmt.Errorf("not implemented in mock")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +165,9 @@ func TestSecretVersionSave(t *testing.T) {
|
|||||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||||
testValue := []byte("test-secret-value")
|
testValue := []byte("test-secret-value")
|
||||||
|
|
||||||
err = sv.Save(testValue)
|
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify files were created
|
// Verify files were created
|
||||||
@@ -202,7 +205,9 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
sv.Metadata.NotBefore = &epochPlusOne
|
sv.Metadata.NotBefore = &epochPlusOne
|
||||||
sv.Metadata.NotAfter = &now
|
sv.Metadata.NotAfter = &now
|
||||||
|
|
||||||
err = sv.Save([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create new version object and load metadata
|
// Create new version object and load metadata
|
||||||
@@ -241,15 +246,20 @@ func TestSecretVersionGetValue(t *testing.T) {
|
|||||||
// Create and save a version
|
// Create and save a version
|
||||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||||
originalValue := []byte("test-secret-value-12345")
|
originalValue := []byte("test-secret-value-12345")
|
||||||
|
expectedValue := make([]byte, len(originalValue))
|
||||||
|
copy(expectedValue, originalValue)
|
||||||
|
|
||||||
err = sv.Save(originalValue)
|
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||||
|
defer originalBuffer.Destroy()
|
||||||
|
err = sv.Save(originalBuffer)
|
||||||
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, originalValue, retrievedValue)
|
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersions(t *testing.T) {
|
func TestListVersions(t *testing.T) {
|
||||||
@@ -286,12 +296,12 @@ func TestGetCurrentVersion(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := "/test/secret"
|
||||||
|
|
||||||
// Simulate symlink with file content (works for both OsFs and MemMapFs)
|
// The current file contains just the version name
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
err := fs.MkdirAll(secretDir, 0o755)
|
err := fs.MkdirAll(secretDir, 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0o600)
|
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
version, err := GetCurrentVersion(fs, secretDir)
|
version, err := GetCurrentVersion(fs, secretDir)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"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"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,9 +26,9 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
// Test symlink handling
|
// Test currentvault file handling (plain file with relative path)
|
||||||
t.Run("SymlinkHandling", func(t *testing.T) {
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "symlink-test")
|
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
@@ -44,31 +45,26 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a symlink to the vault directory in a different location
|
// Verify the currentvault file exists and contains just the vault name
|
||||||
symlinkPath := filepath.Join(tempDir, "test-symlink")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
if err := os.Symlink(vaultDir, symlinkPath); err != nil {
|
currentVaultContents, err := os.ReadFile(currentVaultPath)
|
||||||
t.Fatalf("Failed to create symlink: %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that we can resolve the symlink correctly
|
expectedVaultName := "test-vault"
|
||||||
resolvedPath, err := vault.ResolveVaultSymlink(fs, symlinkPath)
|
if string(currentVaultContents) != expectedVaultName {
|
||||||
if err != nil {
|
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents))
|
||||||
t.Fatalf("Failed to resolve symlink: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// On some platforms, the resolved path might have different case or format
|
// Test that ResolveVaultSymlink correctly resolves the path
|
||||||
// We'll use filepath.EvalSymlinks to get the canonical path for comparison
|
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
||||||
expectedPath, err := filepath.EvalSymlinks(vaultDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to evaluate symlink: %v", err)
|
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
||||||
}
|
|
||||||
actualPath, err := filepath.EvalSymlinks(resolvedPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to evaluate resolved path: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if actualPath != expectedPath {
|
if resolvedPath != vaultDir {
|
||||||
t.Errorf("Expected symlink to resolve to %s, got %s", expectedPath, actualPath)
|
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,8 +103,13 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
// Create a secret with a deeply nested path
|
// Create a secret with a deeply nested path
|
||||||
deepPath := "api/credentials/production/database/primary"
|
deepPath := "api/credentials/production/database/primary"
|
||||||
secretValue := []byte("supersecretdbpassword")
|
secretValue := []byte("supersecretdbpassword")
|
||||||
|
expectedValue := make([]byte, len(secretValue))
|
||||||
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
err = vlt.AddSecret(deepPath, secretValue, false)
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||||
}
|
}
|
||||||
@@ -137,9 +138,9 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(retrievedValue) != string(secretValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||||
string(secretValue), string(retrievedValue))
|
string(expectedValue), string(retrievedValue))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,7 +369,11 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
// Add a secret to vault1
|
// Add a secret to vault1
|
||||||
secretName := "test-secret"
|
secretName := "test-secret"
|
||||||
secretValue := []byte("secret in vault1")
|
secretValue := []byte("secret in vault1")
|
||||||
if err := vault1.AddSecret(secretName, secretValue, false); err != nil {
|
|
||||||
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
if err := vault1.AddSecret(secretName, secretBuffer, false); err != nil {
|
||||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,30 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
|
func addTestSecret(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||||
|
t.Helper()
|
||||||
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
|
defer buffer.Destroy()
|
||||||
|
err := vault.AddSecret(name, buffer, force)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
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
|
||||||
vault, err := CreateVault(fs, stateDir, "test")
|
vault, err := CreateVault(fs, stateDir, "test")
|
||||||
@@ -64,8 +76,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
// Step 1: Create initial version
|
// Step 1: Create initial version
|
||||||
t.Run("create_initial_version", func(t *testing.T) {
|
t.Run("create_initial_version", func(t *testing.T) {
|
||||||
err := vault.AddSecret(secretName, []byte("version-1-data"), false)
|
addTestSecret(t, vault, secretName, []byte("version-1-data"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify secret can be retrieved
|
// Verify secret can be retrieved
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
@@ -106,8 +117,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
firstVersionName = versions[0]
|
firstVersionName = versions[0]
|
||||||
|
|
||||||
// Create second version
|
// Create second version
|
||||||
err = vault.AddSecret(secretName, []byte("version-2-data"), true)
|
addTestSecret(t, vault, secretName, []byte("version-2-data"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify new value is current
|
// Verify new value is current
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
@@ -140,8 +150,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
t.Run("create_third_version", func(t *testing.T) {
|
t.Run("create_third_version", func(t *testing.T) {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
err := vault.AddSecret(secretName, []byte("version-3-data"), true)
|
addTestSecret(t, vault, secretName, []byte("version-3-data"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify we now have three versions
|
// Verify we now have three versions
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
@@ -212,8 +221,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||||
|
|
||||||
// Create 998 versions (we already have one from the first AddSecret)
|
// Create 998 versions (we already have one from the first AddSecret)
|
||||||
err := vault.AddSecret(limitSecretName, []byte("initial"), false)
|
addTestSecret(t, vault, limitSecretName, []byte("initial"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get today's date for consistent version names
|
// Get today's date for consistent version names
|
||||||
today := time.Now().Format("20060102")
|
today := time.Now().Format("20060102")
|
||||||
@@ -253,7 +261,9 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// Try to add secret without force when it exists
|
// Try to add secret without force when it exists
|
||||||
err = vault.AddSecret(secretName, []byte("should-fail"), false)
|
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||||
|
defer failBuffer.Destroy()
|
||||||
|
err = vault.AddSecret(secretName, failBuffer, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
})
|
})
|
||||||
@@ -270,15 +280,14 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
secretName := "concurrent/test"
|
secretName := "concurrent/test"
|
||||||
|
|
||||||
// Create initial version
|
// Create initial version
|
||||||
err := vault.AddSecret(secretName, []byte("initial"), false)
|
addTestSecret(t, vault, secretName, []byte("initial"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Test concurrent reads
|
// Test concurrent reads
|
||||||
t.Run("concurrent_reads", func(t *testing.T) {
|
t.Run("concurrent_reads", func(t *testing.T) {
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
errors := make(chan error, 10)
|
errors := make(chan error, 10)
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
go func() {
|
go func() {
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,7 +300,7 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines
|
// Wait for all goroutines
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +333,10 @@ func TestVersionCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
// Create old-style encrypted value directly in secret directory
|
// Create old-style encrypted value directly in secret directory
|
||||||
testValue := []byte("legacy-value")
|
testValue := []byte("legacy-value")
|
||||||
|
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
|
defer testValueBuffer.Destroy()
|
||||||
ltRecipient := ltIdentity.Recipient()
|
ltRecipient := ltIdentity.Recipient()
|
||||||
encrypted, err := secret.EncryptToRecipient(testValue, ltRecipient)
|
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
valuePath := filepath.Join(secretDir, "value.age")
|
valuePath := filepath.Join(secretDir, "value.age")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package vault provides functionality for managing encrypted vaults.
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
@@ -26,83 +28,33 @@ func isValidVaultName(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||||
|
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
|
// The file contains just the vault name (e.g., "default")
|
||||||
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
|
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
||||||
|
|
||||||
// First try to handle the path as a real symlink (works on Unix systems)
|
fileData, err := afero.ReadFile(fs, currentVaultPath)
|
||||||
if _, ok := fs.(*afero.OsFs); ok {
|
|
||||||
secret.Debug("Using real filesystem symlink resolution")
|
|
||||||
|
|
||||||
// Check if the symlink exists
|
|
||||||
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
|
|
||||||
target, err := os.Readlink(symlinkPath)
|
|
||||||
if err == nil {
|
|
||||||
secret.Debug("Symlink points to", "target", target)
|
|
||||||
|
|
||||||
// On real filesystem, we need to handle relative symlinks
|
|
||||||
// by resolving them relative to the symlink's directory
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
// Get the current directory before changing
|
|
||||||
originalDir, err := os.Getwd()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
secret.Debug("Failed to read currentvault file", "error", err)
|
||||||
}
|
|
||||||
secret.Debug("Got current directory", "original_dir", originalDir)
|
|
||||||
|
|
||||||
// Change to the symlink's directory
|
return "", fmt.Errorf("failed to read currentvault file: %w", err)
|
||||||
symlinkDir := filepath.Dir(symlinkPath)
|
|
||||||
secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
|
|
||||||
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
|
|
||||||
if err := os.Chdir(symlinkDir); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
|
|
||||||
|
|
||||||
// Get the absolute path of the target
|
|
||||||
secret.Debug("Getting absolute path of current directory")
|
|
||||||
absolutePath, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
// Try to restore original directory before returning error
|
|
||||||
_ = os.Chdir(originalDir)
|
|
||||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Got absolute path", "absolute_path", absolutePath)
|
|
||||||
|
|
||||||
// Restore the original directory
|
|
||||||
secret.Debug("Restoring original directory", "original_dir", originalDir)
|
|
||||||
if err := os.Chdir(originalDir); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to restore original directory: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Restored original directory successfully")
|
|
||||||
|
|
||||||
// Use the absolute path of the target
|
|
||||||
target = absolutePath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("resolveVaultSymlink completed successfully", "result", target)
|
// The file contains just the vault name like "default"
|
||||||
return target, nil
|
vaultName := strings.TrimSpace(string(fileData))
|
||||||
}
|
secret.Debug("Read vault name from file", "vault_name", vaultName)
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: treat it as a regular file containing the target path
|
// Resolve to absolute path: stateDir/vaults.d/vaultName
|
||||||
secret.Debug("Fallback: trying to read regular file with target path")
|
stateDir := filepath.Dir(currentVaultPath)
|
||||||
|
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
|
||||||
fileData, err := afero.ReadFile(fs, symlinkPath)
|
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read target path file", "error", err)
|
|
||||||
return "", fmt.Errorf("failed to read vault symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := string(fileData)
|
return absolutePath, nil
|
||||||
secret.Debug("Read target path from file", "target", target)
|
|
||||||
|
|
||||||
secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
|
|
||||||
return target, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVault gets the current vault from the file system
|
// GetCurrentVault gets the current vault from the file system
|
||||||
@@ -116,6 +68,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
_, err := fs.Stat(currentVaultPath)
|
_, err := fs.Stat(currentVaultPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +124,54 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
return vaults, nil
|
return vaults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processMnemonicForVault handles mnemonic processing for vault creation
|
||||||
|
func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string) (
|
||||||
|
derivationIndex uint32, publicKeyHash string, familyHash string, err error) {
|
||||||
|
// Check if mnemonic is available in environment
|
||||||
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
|
||||||
|
if mnemonic == "" {
|
||||||
|
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", vaultName)
|
||||||
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
|
return 0, "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", vaultName)
|
||||||
|
|
||||||
|
// Get the next available derivation index for this mnemonic
|
||||||
|
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the long-term key using the actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the public key
|
||||||
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||||
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
|
}
|
||||||
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
|
|
||||||
|
// Compute verification hash from actual derivation index
|
||||||
|
publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||||
|
|
||||||
|
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
||||||
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
|
}
|
||||||
|
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
|
return derivationIndex, publicKeyHash, familyHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault
|
||||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
@@ -178,6 +179,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
// Validate vault name
|
// Validate vault name
|
||||||
if !isValidVaultName(name) {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||||
}
|
}
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
@@ -203,54 +205,14 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if mnemonic is available in environment
|
// Process mnemonic if available
|
||||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name)
|
||||||
var derivationIndex uint32
|
|
||||||
var publicKeyHash string
|
|
||||||
var familyHash string
|
|
||||||
|
|
||||||
if mnemonic != "" {
|
|
||||||
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
|
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
|
||||||
var err error
|
|
||||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get next derivation index: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the long-term key using the actual derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to derive long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the public key
|
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
||||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write long-term public key: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
|
||||||
|
|
||||||
// Compute verification hash from actual derivation index
|
|
||||||
publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
|
||||||
|
|
||||||
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
|
||||||
// This is used to identify which vaults belong to the same mnemonic family
|
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
|
||||||
}
|
|
||||||
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
|
||||||
} else {
|
|
||||||
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
|
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
|
||||||
derivationIndex = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save vault metadata
|
// Save vault metadata
|
||||||
metadata := &VaultMetadata{
|
metadata := &Metadata{
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
DerivationIndex: derivationIndex,
|
DerivationIndex: derivationIndex,
|
||||||
PublicKeyHash: publicKeyHash,
|
PublicKeyHash: publicKeyHash,
|
||||||
@@ -268,6 +230,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
|
|
||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
secret.Debug("Successfully created vault", "name", name)
|
secret.Debug("Successfully created vault", "name", name)
|
||||||
|
|
||||||
return NewVault(fs, stateDir, name), nil
|
return NewVault(fs, stateDir, name), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +241,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
|||||||
// Validate vault name
|
// Validate vault name
|
||||||
if !isValidVaultName(name) {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||||
}
|
}
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
@@ -292,34 +256,22 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
|||||||
return fmt.Errorf("vault %s does not exist", name)
|
return fmt.Errorf("vault %s does not exist", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update the current vault symlink/file
|
// Create or update the currentvault file with just the vault name
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
targetPath := filepath.Join(stateDir, "vaults.d", name)
|
|
||||||
|
|
||||||
// First try to remove existing symlink if it exists
|
// Remove existing file if it exists
|
||||||
if _, err := fs.Stat(currentVaultPath); err == nil {
|
if _, err := fs.Stat(currentVaultPath); err == nil {
|
||||||
secret.Debug("Removing existing current vault symlink", "path", currentVaultPath)
|
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
||||||
// Ignore errors from Remove as we'll try to create/update it anyway.
|
|
||||||
// On some systems, removing a symlink may fail but the subsequent create may still succeed.
|
|
||||||
_ = fs.Remove(currentVaultPath)
|
_ = fs.Remove(currentVaultPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create a real symlink first (works on Unix systems)
|
// Write just the vault name to the file
|
||||||
if _, ok := fs.(*afero.OsFs); ok {
|
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||||
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
|
if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil {
|
||||||
if err := os.Symlink(targetPath, currentVaultPath); err == nil {
|
|
||||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// If symlink creation fails, fall back to regular file
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: create a regular file with the target path
|
|
||||||
secret.Debug("Fallback: creating regular file with target path", "target", targetPath)
|
|
||||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), secret.FilePerms); err != nil {
|
|
||||||
return fmt.Errorf("failed to select vault: %w", err)
|
return fmt.Errorf("failed to select vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
secret.Debug("Successfully selected vault", "vault_name", name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,23 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alias the metadata types from secret package for convenience
|
// Metadata is an alias for secret.VaultMetadata
|
||||||
type (
|
type Metadata = secret.VaultMetadata
|
||||||
VaultMetadata = secret.VaultMetadata
|
|
||||||
UnlockerMetadata = secret.UnlockerMetadata
|
// UnlockerMetadata is an alias for secret.UnlockerMetadata
|
||||||
SecretMetadata = secret.Metadata
|
type UnlockerMetadata = secret.UnlockerMetadata
|
||||||
Configuration = secret.Configuration
|
|
||||||
)
|
// SecretMetadata is an alias for secret.Metadata
|
||||||
|
type SecretMetadata = secret.Metadata
|
||||||
|
|
||||||
|
// Configuration is an alias for secret.Configuration
|
||||||
|
type Configuration = secret.Configuration
|
||||||
|
|
||||||
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
|
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
|
||||||
func ComputeDoubleSHA256(data []byte) string {
|
func ComputeDoubleSHA256(data []byte) string {
|
||||||
firstHash := sha256.Sum256(data)
|
firstHash := sha256.Sum256(data)
|
||||||
secondHash := sha256.Sum256(firstHash[:])
|
secondHash := sha256.Sum256(firstHash[:])
|
||||||
|
|
||||||
return hex.EncodeToString(secondHash[:])
|
return hex.EncodeToString(secondHash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata VaultMetadata
|
var metadata Metadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
// Skip vaults with invalid metadata
|
// Skip vaults with invalid metadata
|
||||||
continue
|
continue
|
||||||
@@ -84,7 +89,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the first available index
|
// Find the first available index
|
||||||
var index uint32 = 0
|
var index uint32
|
||||||
for usedIndices[index] {
|
for usedIndices[index] {
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
@@ -93,7 +98,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveVaultMetadata saves vault metadata to the vault directory
|
// SaveVaultMetadata saves vault metadata to the vault directory
|
||||||
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *VaultMetadata) error {
|
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
|
||||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||||
@@ -109,7 +114,7 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *VaultMetadata) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadVaultMetadata loads vault metadata from the vault directory
|
// LoadVaultMetadata loads vault metadata from the vault directory
|
||||||
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*VaultMetadata, error) {
|
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
@@ -117,7 +122,7 @@ func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*VaultMetadata, error) {
|
|||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata VaultMetadata
|
var metadata Metadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestVaultMetadata(t *testing.T) {
|
|||||||
t.Fatalf("Failed to write public key: %v", err)
|
t.Fatalf("Failed to write public key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata1 := &VaultMetadata{
|
metadata1 := &Metadata{
|
||||||
DerivationIndex: 0,
|
DerivationIndex: 0,
|
||||||
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
|
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
|
||||||
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
|
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
|
||||||
@@ -117,7 +117,7 @@ func TestVaultMetadata(t *testing.T) {
|
|||||||
// Compute the hash for index 5 key
|
// Compute the hash for index 5 key
|
||||||
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
|
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
|
||||||
|
|
||||||
metadata2 := &VaultMetadata{
|
metadata2 := &Metadata{
|
||||||
DerivationIndex: 5,
|
DerivationIndex: 5,
|
||||||
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
|
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
|
||||||
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
|
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
|
||||||
@@ -143,7 +143,7 @@ func TestVaultMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and save metadata
|
// Create and save metadata
|
||||||
metadata := &VaultMetadata{
|
metadata := &Metadata{
|
||||||
DerivationIndex: 3,
|
DerivationIndex: 3,
|
||||||
PublicKeyHash: "test-public-key-hash",
|
PublicKeyHash: "test-public-key-hash",
|
||||||
}
|
}
|
||||||
|
|||||||
96
internal/vault/path_traversal_test.go
Normal file
96
internal/vault/path_traversal_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||||
|
// validates the secret name and rejects path traversal attempts.
|
||||||
|
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||||
|
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add a legitimate secret so the vault is set up
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
|
||||||
|
err = vlt.AddSecret("legit", value, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// These names contain path traversal and should be rejected
|
||||||
|
maliciousNames := []string{
|
||||||
|
"../../../etc/passwd",
|
||||||
|
"..%2f..%2fetc/passwd",
|
||||||
|
".secret",
|
||||||
|
"../sibling-vault/secrets.d/target",
|
||||||
|
"foo/../bar",
|
||||||
|
"a/../../etc/passwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range maliciousNames {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
|
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
|
||||||
|
assert.Contains(t, err.Error(), "invalid secret name",
|
||||||
|
"error should indicate invalid name for: %s", name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls GetSecretVersion)
|
||||||
|
// also rejects path traversal names.
|
||||||
|
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid secret name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
|
// also validates names and rejects path traversal attempts.
|
||||||
|
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
maliciousNames := []string{
|
||||||
|
"../../../etc/passwd",
|
||||||
|
"foo/../bar",
|
||||||
|
"a/../../etc/passwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range maliciousNames {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := vlt.GetSecretObject(name)
|
||||||
|
assert.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||||
|
assert.Contains(t, err.Error(), "invalid secret name")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +32,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
|
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
|
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
|
||||||
|
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
|
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +67,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
return secrets, nil
|
return secrets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
|
// isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
|
||||||
// but with additional restrictions:
|
// but with additional restrictions:
|
||||||
// - No leading or trailing slashes
|
// - No leading or trailing slashes
|
||||||
// - No double slashes
|
// - No double slashes
|
||||||
@@ -87,23 +92,36 @@ func isValidSecretName(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for path traversal via ".." components
|
||||||
|
for _, part := range strings.Split(name, "/") {
|
||||||
|
if part == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check the basic pattern
|
// Check the basic pattern
|
||||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
||||||
|
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSecret adds a secret to this vault
|
// AddSecret adds a secret to this vault
|
||||||
func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||||
|
if value == nil {
|
||||||
|
return fmt.Errorf("value buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
secret.DebugWith("Adding secret to vault",
|
secret.DebugWith("Adding secret to vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.Int("value_length", len(value)),
|
slog.Int("value_length", value.Size()),
|
||||||
slog.Bool("force", force),
|
slog.Bool("force", force),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate secret name
|
// Validate secret name
|
||||||
if !isValidSecretName(name) {
|
if !isValidSecretName(name) {
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
||||||
}
|
}
|
||||||
secret.Debug("Secret name validation passed", "secret_name", name)
|
secret.Debug("Secret name validation passed", "secret_name", name)
|
||||||
@@ -112,6 +130,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||||
@@ -130,6 +149,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
exists, err := afero.DirExists(v.fs, secretDir)
|
exists, err := afero.DirExists(v.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
secret.Debug("Secret existence check complete", "exists", exists)
|
secret.Debug("Secret existence check complete", "exists", exists)
|
||||||
@@ -141,6 +161,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
if exists {
|
if exists {
|
||||||
if !force {
|
if !force {
|
||||||
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
||||||
|
|
||||||
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +176,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||||
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
||||||
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||||
}
|
}
|
||||||
secret.Debug("Created secret directory successfully")
|
secret.Debug("Created secret directory successfully")
|
||||||
@@ -164,6 +186,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("failed to generate version name: %w", err)
|
return fmt.Errorf("failed to generate version name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +207,16 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
// We'll update the previous version's notAfter after we save the new version
|
// We'll update the previous version's notAfter after we save the new version
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the new version
|
// Save the new version - pass the LockedBuffer directly
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,12 +226,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load previous version metadata
|
// Load previous version metadata
|
||||||
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
||||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +243,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
// Re-save the metadata (we need to implement an update method)
|
// Re-save the metadata (we need to implement an update method)
|
||||||
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
||||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,10 +251,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
|||||||
// Set current symlink to new version
|
// Set current symlink to new version
|
||||||
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
||||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||||
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Successfully added secret version to vault", "secret_name", name, "version", versionName, "vault_name", v.Name)
|
secret.Debug("Successfully added secret version to vault",
|
||||||
|
"secret_name", name, "version", versionName,
|
||||||
|
"vault_name", v.Name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +272,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)
|
||||||
}
|
}
|
||||||
@@ -253,7 +291,10 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt metadata to the version's public key
|
// Encrypt metadata to the version's public key
|
||||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||||
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -285,10 +326,18 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Validate secret name to prevent path traversal
|
||||||
|
if !isValidSecretName(name) {
|
||||||
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
||||||
|
}
|
||||||
|
|
||||||
// Get vault directory
|
// Get vault directory
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,10 +349,12 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
exists, err := afero.DirExists(v.fs, secretDir)
|
exists, err := afero.DirExists(v.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("secret %s not found", name)
|
return nil, fmt.Errorf("secret %s not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +364,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||||
}
|
}
|
||||||
version = currentVersion
|
version = currentVersion
|
||||||
@@ -327,10 +379,12 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
exists, err = afero.DirExists(v.fs, versionPath)
|
exists, err = afero.DirExists(v.fs, versionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +394,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
longTermIdentity, err := v.UnlockVault()
|
longTermIdentity, err := v.UnlockVault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to unlock vault: %w", err)
|
return nil, fmt.Errorf("failed to unlock vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,24 +410,30 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
||||||
|
|
||||||
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
|
||||||
@@ -382,6 +443,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
// If vault is already unlocked, return the cached key
|
// If vault is already unlocked, return the cached key
|
||||||
if !v.Locked() {
|
if !v.Locked() {
|
||||||
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
||||||
|
|
||||||
return v.longTermKey, nil
|
return v.longTermKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +451,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +468,10 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||||
|
if !isValidSecretName(name) {
|
||||||
|
return nil, fmt.Errorf("invalid secret name: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
// First check if the secret exists by checking for the metadata file
|
// First check if the secret exists by checking for the metadata file
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -434,3 +501,158 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
|
|
||||||
return secretObj, nil
|
return secretObj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CopySecretVersion copies a single version from source to this vault
|
||||||
|
// It decrypts the value using srcIdentity and re-encrypts for this vault
|
||||||
|
func (v *Vault) CopySecretVersion(
|
||||||
|
srcVersion *secret.Version,
|
||||||
|
srcIdentity *age.X25519Identity,
|
||||||
|
destSecretName string,
|
||||||
|
destVersionName string,
|
||||||
|
) error {
|
||||||
|
secret.DebugWith("Copying secret version to vault",
|
||||||
|
slog.String("src_secret", srcVersion.SecretName),
|
||||||
|
slog.String("src_version", srcVersion.Version),
|
||||||
|
slog.String("dest_vault", v.Name),
|
||||||
|
slog.String("dest_secret", destSecretName),
|
||||||
|
slog.String("dest_version", destVersionName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the decrypted value from source
|
||||||
|
valueBuffer, err := srcVersion.GetValue(srcIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt source version: %w", err)
|
||||||
|
}
|
||||||
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
|
// Load source metadata
|
||||||
|
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
|
||||||
|
return fmt.Errorf("failed to load source metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination version with same name
|
||||||
|
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
||||||
|
|
||||||
|
// Copy metadata (preserve original timestamps)
|
||||||
|
destVersion.Metadata = srcVersion.Metadata
|
||||||
|
|
||||||
|
// Save the version (encrypts to this vault's LT key)
|
||||||
|
if err := destVersion.Save(valueBuffer); err != nil {
|
||||||
|
return fmt.Errorf("failed to save destination version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Successfully copied secret version",
|
||||||
|
"src_version", srcVersion.Version,
|
||||||
|
"dest_version", destVersionName,
|
||||||
|
"dest_vault", v.Name)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
|
||||||
|
// It re-encrypts each version with this vault's long-term key
|
||||||
|
func (v *Vault) CopySecretAllVersions(
|
||||||
|
srcVault *Vault,
|
||||||
|
srcSecretName string,
|
||||||
|
destSecretName string,
|
||||||
|
force bool,
|
||||||
|
) error {
|
||||||
|
secret.DebugWith("Copying all secret versions between vaults",
|
||||||
|
slog.String("src_vault", srcVault.Name),
|
||||||
|
slog.String("src_secret", srcSecretName),
|
||||||
|
slog.String("dest_vault", v.Name),
|
||||||
|
slog.String("dest_secret", destSecretName),
|
||||||
|
slog.Bool("force", force),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get destination vault directory
|
||||||
|
destVaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination secret already exists
|
||||||
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||||
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists && !force {
|
||||||
|
return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)",
|
||||||
|
destSecretName, v.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists && force {
|
||||||
|
// Remove existing secret
|
||||||
|
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
||||||
|
if err := v.fs.RemoveAll(destSecretDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source vault's long-term key
|
||||||
|
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source secret directory
|
||||||
|
srcVaultDir, err := srcVault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||||
|
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||||
|
|
||||||
|
// List all versions
|
||||||
|
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list source versions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version name
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination secret directory
|
||||||
|
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy each version
|
||||||
|
for _, versionName := range versions {
|
||||||
|
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
||||||
|
if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil {
|
||||||
|
// Rollback: remove partial copy
|
||||||
|
secret.Debug("Rolling back partial copy due to error", "error", err)
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current version
|
||||||
|
if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil {
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.DebugWith("Successfully copied all secret versions",
|
||||||
|
slog.String("src_vault", srcVault.Name),
|
||||||
|
slog.String("dest_vault", v.Name),
|
||||||
|
slog.Int("version_count", len(versions)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
42
internal/vault/secrets_name_test.go
Normal file
42
internal/vault/secrets_name_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
// Lowercase (existing behavior)
|
||||||
|
{"valid-name", true},
|
||||||
|
{"valid.name", true},
|
||||||
|
{"valid_name", true},
|
||||||
|
{"valid/path/name", true},
|
||||||
|
{"123valid", true},
|
||||||
|
|
||||||
|
// Uppercase (new behavior - issue #2)
|
||||||
|
{"Valid-Upper-Name", true},
|
||||||
|
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
|
||||||
|
{"MixedCase/Path/Name", true},
|
||||||
|
{"ALLUPPERCASE", true},
|
||||||
|
{"ABC123", true},
|
||||||
|
|
||||||
|
// Still invalid
|
||||||
|
{"", false},
|
||||||
|
{"invalid name", false},
|
||||||
|
{"invalid@name", false},
|
||||||
|
{".dotstart", false},
|
||||||
|
{"/leading-slash", false},
|
||||||
|
{"trailing-slash/", false},
|
||||||
|
{"double//slash", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isValidSecretName(tt.name)
|
||||||
|
if result != tt.valid {
|
||||||
|
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,21 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
|
func addTestSecretToVault(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||||
|
t.Helper()
|
||||||
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
|
defer buffer.Destroy()
|
||||||
|
err := vault.AddSecret(name, buffer, force)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to create a vault with long-term key set up
|
// Helper function to create a vault with long-term key set up
|
||||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
@@ -65,9 +75,10 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
// Add a secret
|
// Add a secret
|
||||||
secretName := "test/secret"
|
secretName := "test/secret"
|
||||||
secretValue := []byte("initial-value")
|
secretValue := []byte("initial-value")
|
||||||
|
expectedValue := make([]byte, len(secretValue))
|
||||||
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
err := vault.AddSecret(secretName, secretValue, false)
|
addTestSecretToVault(t, vault, secretName, secretValue, false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Check that version directory was created
|
// Check that version directory was created
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -88,7 +99,7 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
// Get the secret value
|
// Get the secret value
|
||||||
retrievedValue, err := vault.GetSecret(secretName)
|
retrievedValue, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, secretValue, retrievedValue)
|
assert.Equal(t, expectedValue, retrievedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||||
@@ -101,17 +112,17 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
secretName := "test/secret"
|
secretName := "test/secret"
|
||||||
|
|
||||||
// Add first version
|
// Add first version
|
||||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to add again without force - should fail
|
// Try to add again without force - should fail
|
||||||
err = vault.AddSecret(secretName, []byte("version-2"), false)
|
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||||
|
defer failBuffer.Destroy()
|
||||||
|
err := vault.AddSecret(secretName, failBuffer, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
|
||||||
// Add with force - should create new version
|
// Add with force - should create new version
|
||||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Check that we have two versions
|
// Check that we have two versions
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -136,14 +147,12 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
secretName := "test/secret"
|
secretName := "test/secret"
|
||||||
|
|
||||||
// Add multiple versions
|
// Add multiple versions
|
||||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Small delay to ensure different version names
|
// Small delay to ensure different version names
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get versions list
|
// Get versions list
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -185,7 +194,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
|
|
||||||
// Add first version
|
// Add first version
|
||||||
beforeFirst := time.Now()
|
beforeFirst := time.Now()
|
||||||
err = vault.AddSecret(secretName, []byte("version-1"), false)
|
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||||
|
defer v1Buffer.Destroy()
|
||||||
|
err = vault.AddSecret(secretName, v1Buffer, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
afterFirst := time.Now()
|
afterFirst := time.Now()
|
||||||
|
|
||||||
@@ -212,8 +223,7 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
// Add second version
|
// Add second version
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
beforeSecond := time.Now()
|
beforeSecond := time.Now()
|
||||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
require.NoError(t, err)
|
|
||||||
afterSecond := time.Now()
|
afterSecond := time.Now()
|
||||||
|
|
||||||
// Get updated versions
|
// Get updated versions
|
||||||
@@ -249,11 +259,10 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
|
|||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
err := vault.AddSecret("test/secret", []byte("value"), false)
|
addTestSecretToVault(t, vault, "test/secret", []byte("value"), false)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to get non-existent version
|
// Try to get non-existent version
|
||||||
_, err = vault.GetSecretVersion("test/secret", "20991231.999")
|
_, err := vault.GetSecretVersion("test/secret", "20991231.999")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
@@ -281,7 +290,9 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
|||||||
version.Metadata.NotAfter = nil
|
version.Metadata.NotAfter = nil
|
||||||
|
|
||||||
// Save version
|
// Save version
|
||||||
err = version.Save([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
err = version.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Update metadata
|
// Update metadata
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,34 +31,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
_, err = v.fs.Stat(currentUnlockerPath)
|
_, err = v.fs.Stat(currentUnlockerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the symlink to get the target directory
|
// Resolve the symlink to get the target directory
|
||||||
var unlockerDir string
|
unlockerDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
||||||
if linkReader, ok := v.fs.(afero.LinkReader); ok {
|
|
||||||
secret.Debug("Resolving unlocker symlink using afero")
|
|
||||||
// Try to read as symlink first
|
|
||||||
unlockerDir, err = linkReader.ReadlinkIfPossible(currentUnlockerPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read symlink, falling back to file contents", "error", err, "symlink_path", currentUnlockerPath)
|
return nil, err
|
||||||
// Fallback: read the path from file contents
|
|
||||||
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
|
|
||||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
|
||||||
}
|
|
||||||
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
secret.Debug("Reading unlocker path (filesystem doesn't support symlinks)")
|
|
||||||
// Fallback for filesystems that don't support symlinks: read the path from file contents
|
|
||||||
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
|
|
||||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
|
||||||
}
|
|
||||||
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Resolved unlocker directory",
|
secret.DebugWith("Resolved unlocker directory",
|
||||||
@@ -71,12 +53,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
var metadata UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,20 +72,23 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
|
|
||||||
// Create unlocker instance using direct constructors with filesystem
|
// Create unlocker instance using direct constructors with filesystem
|
||||||
var unlocker secret.Unlocker
|
var unlocker secret.Unlocker
|
||||||
// Convert our metadata to secret.UnlockerMetadata
|
// Use metadata directly as it's already the correct type
|
||||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
|
||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case "passphrase":
|
case "passphrase":
|
||||||
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
||||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
|
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "pgp":
|
case "pgp":
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata)
|
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "keychain":
|
case "keychain":
|
||||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata)
|
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
case "secure-enclave":
|
||||||
|
secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
||||||
default:
|
default:
|
||||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||||
|
|
||||||
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +101,89 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return unlocker, nil
|
return unlocker, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveUnlockerDirectory reads the current-unlocker file to get the unlocker directory path
|
||||||
|
// The file contains just the unlocker name (e.g., "passphrase")
|
||||||
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||||
|
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
||||||
|
|
||||||
|
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerName := strings.TrimSpace(string(unlockerNameBytes))
|
||||||
|
secret.Debug("Read unlocker name from file", "unlocker_name", unlockerName)
|
||||||
|
|
||||||
|
// Resolve to absolute path: vaultDir/unlockers.d/unlockerName
|
||||||
|
vaultDir := filepath.Dir(currentUnlockerPath)
|
||||||
|
absolutePath := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
||||||
|
|
||||||
|
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||||
|
|
||||||
|
return absolutePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
|
||||||
|
func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) {
|
||||||
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read metadata file
|
||||||
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||||
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
// Skip directories without metadata - they might not be unlockers
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||||
|
|
||||||
|
// Create the appropriate unlocker instance
|
||||||
|
var tempUnlocker secret.Unlocker
|
||||||
|
switch metadata.Type {
|
||||||
|
case "passphrase":
|
||||||
|
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
|
case "pgp":
|
||||||
|
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
|
case "keychain":
|
||||||
|
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
|
case "secure-enclave":
|
||||||
|
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this unlocker's ID matches
|
||||||
|
if tempUnlocker.GetID() == unlockerID {
|
||||||
|
return tempUnlocker, unlockerDirPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns a list of available unlockers for this vault
|
||||||
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
@@ -148,7 +218,9 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
|
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
@@ -178,61 +250,10 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
// Find the unlocker directory and create the unlocker instance
|
// Find the unlocker directory and create the unlocker instance
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// List directories in unlockers.d
|
// Find the unlocker by ID
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read unlockers directory: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
var unlockerDirPath string
|
|
||||||
for _, file := range files {
|
|
||||||
if file.IsDir() {
|
|
||||||
// Read metadata file
|
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
// Skip directories without metadata - they might not be unlockers
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirPath = filepath.Join(unlockersDir, file.Name())
|
|
||||||
|
|
||||||
// Convert our metadata to secret.UnlockerMetadata
|
|
||||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
var tempUnlocker secret.Unlocker
|
|
||||||
switch metadata.Type {
|
|
||||||
case "passphrase":
|
|
||||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
case "pgp":
|
|
||||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
case "keychain":
|
|
||||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this unlocker's ID matches
|
|
||||||
if tempUnlocker.GetID() == unlockerID {
|
|
||||||
unlocker = tempUnlocker
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
@@ -253,97 +274,43 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
// Find the unlocker directory by ID
|
// Find the unlocker directory by ID
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// List directories in unlockers.d to find the unlocker
|
// Find the unlocker by ID
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read unlockers directory: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
var targetUnlockerDir string
|
|
||||||
for _, file := range files {
|
|
||||||
if file.IsDir() {
|
|
||||||
// Read metadata file
|
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
// Skip directories without metadata - they might not be unlockers
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
|
||||||
|
|
||||||
// Convert our metadata to secret.UnlockerMetadata
|
|
||||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
var tempUnlocker secret.Unlocker
|
|
||||||
switch metadata.Type {
|
|
||||||
case "passphrase":
|
|
||||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
case "pgp":
|
|
||||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
case "keychain":
|
|
||||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this unlocker's ID matches
|
|
||||||
if tempUnlocker.GetID() == unlockerID {
|
|
||||||
targetUnlockerDir = unlockerDirPath
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetUnlockerDir == "" {
|
if targetUnlockerDir == "" {
|
||||||
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create/update current unlocker symlink
|
// Create/update current-unlocker file with just the unlocker name
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
// Remove existing symlink if it exists
|
// Remove existing file if it exists
|
||||||
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
||||||
return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err)
|
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||||
} else if exists {
|
} else if exists {
|
||||||
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove existing unlocker symlink: %w", err)
|
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new symlink using afero's SymlinkIfPossible
|
// Get just the unlocker name (basename of the directory)
|
||||||
if linker, ok := v.fs.(afero.Linker); ok {
|
unlockerName := filepath.Base(targetUnlockerDir)
|
||||||
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
|
|
||||||
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
|
// Write just the unlocker name to the file
|
||||||
return fmt.Errorf("failed to create unlocker symlink: %w", err)
|
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||||
}
|
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil {
|
||||||
} else {
|
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||||
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
|
|
||||||
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
|
|
||||||
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
|
|
||||||
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) {
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
|
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
@@ -363,13 +330,17 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
|||||||
|
|
||||||
// Write public key
|
// Write public key
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
if err := afero.WriteFile(v.fs, pubKeyPath,
|
||||||
|
[]byte(unlockerIdentity.Recipient().String()),
|
||||||
|
secret.FilePerms); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt private key with passphrase
|
// Encrypt private key with passphrase
|
||||||
privKeyData := []byte(unlockerIdentity.String())
|
privKeyStr := unlockerIdentity.String()
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, 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)
|
||||||
}
|
}
|
||||||
@@ -405,8 +376,10 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
|||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKey := []byte(ltIdentity.String())
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -416,11 +389,8 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
|||||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert our metadata to secret.UnlockerMetadata for the constructor
|
|
||||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
|
||||||
|
|
||||||
// Create the unlocker instance
|
// Create the unlocker instance
|
||||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
|
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
|
||||||
// Select this unlocker as current
|
// Select this unlocker as current
|
||||||
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
|||||||
longTermKey: nil,
|
longTermKey: nil,
|
||||||
}
|
}
|
||||||
secret.Debug("Created NewVault instance successfully")
|
secret.Debug("Created NewVault instance successfully")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +76,14 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
"derived_hash", derivedPubKeyHash,
|
"derived_hash", derivedPubKeyHash,
|
||||||
"stored_hash", metadata.PublicKeyHash,
|
"stored_hash", metadata.PublicKeyHash,
|
||||||
"derivation_index", metadata.DerivationIndex)
|
"derivation_index", metadata.DerivationIndex)
|
||||||
|
|
||||||
return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
|
return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +119,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
unlocker, err := v.GetCurrentUnlocker()
|
unlocker, err := v.GetCurrentUnlocker()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,50 +129,12 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
slog.String("unlocker_id", unlocker.GetID()),
|
slog.String("unlocker_id", unlocker.GetID()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get unlocker identity
|
// Get the long-term key via the unlocker.
|
||||||
unlockerIdentity, err := unlocker.GetIdentity()
|
// SE unlockers return the long-term key directly from GetIdentity().
|
||||||
|
// Other unlockers return their own identity, used to decrypt longterm.age.
|
||||||
|
ltIdentity, err := v.unlockLongTermKey(unlocker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
|
return nil, err
|
||||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read encrypted long-term private key from unlocker directory
|
|
||||||
unlockerDir := unlocker.GetDirectory()
|
|
||||||
encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
|
||||||
|
|
||||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.DebugWith("Read encrypted long-term private key",
|
|
||||||
slog.String("vault_name", v.Name),
|
|
||||||
slog.String("unlocker_type", unlocker.GetType()),
|
|
||||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decrypt long-term private key using unlocker
|
|
||||||
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
|
||||||
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
|
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted long-term private key",
|
|
||||||
slog.String("vault_name", v.Name),
|
|
||||||
slog.String("unlocker_type", unlocker.GetType()),
|
|
||||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse long-term private key
|
|
||||||
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
|
||||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
|
||||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Successfully obtained long-term identity via unlocker",
|
secret.DebugWith("Successfully obtained long-term identity via unlocker",
|
||||||
@@ -178,7 +145,49 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Cache the derived key by unlocking the vault
|
// Cache the derived key by unlocking the vault
|
||||||
v.Unlock(ltIdentity)
|
v.Unlock(ltIdentity)
|
||||||
secret.Debug("Vault is unlocked (lt key in memory) via unlocker", "vault_name", v.Name, "unlocker_type", unlocker.GetType())
|
secret.Debug("Vault is unlocked (lt key in memory) via unlocker",
|
||||||
|
"vault_name", v.Name, "unlocker_type", unlocker.GetType())
|
||||||
|
|
||||||
|
return ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
|
||||||
|
// SE unlockers decrypt the long-term key directly; other unlockers use an intermediate identity.
|
||||||
|
func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity, error) {
|
||||||
|
if unlocker.GetType() == "secure-enclave" {
|
||||||
|
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
||||||
|
|
||||||
|
ltIdentity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard unlockers: get unlocker identity, then decrypt longterm.age
|
||||||
|
unlockerIdentity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||||
|
|
||||||
|
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return ltIdentity, nil
|
return ltIdentity, nil
|
||||||
}
|
}
|
||||||
@@ -197,3 +206,44 @@ 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 have a "current" version pointer file
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid secret has a "current" file pointing to the active version
|
||||||
|
secretDir := filepath.Join(secretsDir, entry.Name())
|
||||||
|
currentFile := filepath.Join(secretDir, "current")
|
||||||
|
exists, err := afero.Exists(v.fs, currentFile)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip directories we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,8 +122,13 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
// Now add a secret
|
// Now add a secret
|
||||||
secretName := "test/secret"
|
secretName := "test/secret"
|
||||||
secretValue := []byte("test-secret-value")
|
secretValue := []byte("test-secret-value")
|
||||||
|
expectedValue := make([]byte, len(secretValue))
|
||||||
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secretValue, false)
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, secretBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add secret: %v", err)
|
t.Fatalf("Failed to add secret: %v", err)
|
||||||
}
|
}
|
||||||
@@ -151,8 +157,26 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
t.Fatalf("Failed to get secret: %v", err)
|
t.Fatalf("Failed to get secret: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(retrievedValue) != string(secretValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Expected secret value '%s', got '%s'", string(secretValue), string(retrievedValue))
|
t.Errorf("Expected secret value '%s', got '%s'", string(expectedValue), string(retrievedValue))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test NumSecrets
|
||||||
|
t.Run("NumSecrets", func(t *testing.T) {
|
||||||
|
vlt, err := GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
numSecrets, err := vlt.NumSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to count secrets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We added one secret in SecretOperations
|
||||||
|
if numSecrets != 1 {
|
||||||
|
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,7 +196,9 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a passphrase unlocker
|
// Create a passphrase unlocker
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,3 +243,57 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||||
|
// Set test environment variables
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
|
// Use in-memory filesystem
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a passphrase unlocker so we have at least one valid unlocker
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a bogus unlocker directory with no metadata file
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
||||||
|
err = fs.MkdirAll(bogusDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bogus directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnlockers should succeed, skipping the bogus directory
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still have the valid passphrase unlocker
|
||||||
|
if len(unlockers) == 0 {
|
||||||
|
t.Errorf("Expected at least one unlocker, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we only got the valid unlocker(s), not the bogus one
|
||||||
|
for _, u := range unlockers {
|
||||||
|
if u.Type == "" {
|
||||||
|
t.Errorf("Got unlocker with empty type, likely from bogus directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
vendorID = uint32(592366788) // berlin.sneak
|
vendorID = uint32(592366788) // berlin.sneak
|
||||||
appID = uint32(733482323) // secret
|
appID = uint32(733482323) // secret
|
||||||
hrp = "age-secret-key-" // Bech32 HRP used by age
|
hrp = "age-secret-key-" // Bech32 HRP used by age
|
||||||
|
x25519KeySize = 32 // 256-bit key size for X25519
|
||||||
)
|
)
|
||||||
|
|
||||||
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
||||||
@@ -37,16 +38,20 @@ func clamp(k []byte) {
|
|||||||
// IdentityFromEntropy converts 32 deterministic bytes into an
|
// IdentityFromEntropy converts 32 deterministic bytes into an
|
||||||
// *age.X25519Identity by round-tripping through Bech32.
|
// *age.X25519Identity by round-tripping through Bech32.
|
||||||
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||||
if len(ent) != 32 { // 32 bytes = 256-bit key size for X25519
|
if len(ent) != x25519KeySize {
|
||||||
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a copy to avoid modifying the original
|
// Make a copy to avoid modifying the original
|
||||||
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519 // 32 bytes = 256-bit key size for X25519
|
key := make([]byte, x25519KeySize)
|
||||||
copy(key, ent)
|
copy(key, ent)
|
||||||
clamp(key)
|
clamp(key)
|
||||||
|
|
||||||
data, err := bech32.ConvertBits(key, 8, 5, true) // Convert from 8-bit to 5-bit encoding for bech32
|
const (
|
||||||
|
bech32BitSize8 = 8 // Standard 8-bit encoding
|
||||||
|
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
||||||
|
)
|
||||||
|
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
return nil, fmt.Errorf("bech32 convert: %w", err)
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,7 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 encode: %w", err)
|
return nil, fmt.Errorf("bech32 encode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return age.ParseX25519Identity(strings.ToUpper(s))
|
return age.ParseX25519Identity(strings.ToUpper(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
|||||||
|
|
||||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519
|
key := make([]byte, x25519KeySize)
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
@@ -109,7 +115,7 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
|
|||||||
|
|
||||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519
|
key := make([]byte, x25519KeySize)
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
@@ -125,6 +131,7 @@ func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return IdentityFromEntropy(ent)
|
return IdentityFromEntropy(ent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,5 +142,6 @@ func DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return IdentityFromEntropy(ent)
|
return IdentityFromEntropy(ent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
|
|||||||
err,
|
err,
|
||||||
) // In test context, panic is acceptable for setup failures
|
) // In test context, panic is acceptable for setup failures
|
||||||
}
|
}
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}(),
|
}(),
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package bip85 implements BIP85 deterministic entropy derivation.
|
||||||
package bip85
|
package bip85
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,47 +28,50 @@ const (
|
|||||||
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
||||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
|
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
|
|
||||||
// Application numbers
|
// AppBIP39 is the application number for BIP39 mnemonics
|
||||||
APP_BIP39 = 39 // BIP39 mnemonics //nolint:revive // ALL_CAPS used for BIP85 constants
|
AppBIP39 = 39
|
||||||
APP_HD_WIF = 2 // WIF for Bitcoin Core //nolint:revive // ALL_CAPS used for BIP85 constants
|
// AppHDWIF is the application number for WIF (Wallet Import Format) for Bitcoin Core
|
||||||
APP_XPRV = 32 // Extended private key //nolint:revive // ALL_CAPS used for BIP85 constants
|
AppHDWIF = 2
|
||||||
|
// AppXPRV is the application number for extended private key
|
||||||
|
AppXPRV = 32
|
||||||
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
APP_PWD85 = 707785 // Base85 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
AppPWD85 = 707785 // Base85 passwords
|
||||||
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version bytes for extended keys
|
// Version bytes for extended keys
|
||||||
var (
|
var (
|
||||||
// MainNetPrivateKey is the version for mainnet private keys
|
// MainNetPrivateKey is the version for mainnet private keys
|
||||||
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4}
|
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||||
// TestNetPrivateKey is the version for testnet private keys
|
// TestNetPrivateKey is the version for testnet private keys
|
||||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94}
|
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||||
)
|
)
|
||||||
|
|
||||||
// BIP85DRNG is a deterministic random number generator seeded by BIP85 entropy
|
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
||||||
type BIP85DRNG struct {
|
type DRNG struct {
|
||||||
shake io.Reader
|
shake io.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBIP85DRNG creates a new DRNG seeded with BIP85 entropy
|
// NewBIP85DRNG creates a new DRNG seeded with BIP85 entropy
|
||||||
func NewBIP85DRNG(entropy []byte) *BIP85DRNG {
|
func NewBIP85DRNG(entropy []byte) *DRNG {
|
||||||
|
const bip85EntropySize = 64 // 512 bits
|
||||||
// The entropy must be exactly 64 bytes (512 bits)
|
// The entropy must be exactly 64 bytes (512 bits)
|
||||||
if len(entropy) != 64 {
|
if len(entropy) != bip85EntropySize {
|
||||||
panic("BIP85DRNG entropy must be 64 bytes")
|
panic("DRNG entropy must be 64 bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize SHAKE256 with the entropy
|
// Initialize SHAKE256 with the entropy
|
||||||
shake := sha3.NewShake256()
|
shake := sha3.NewShake256()
|
||||||
shake.Write(entropy)
|
_, _ = shake.Write(entropy) // Write to hash functions never returns an error
|
||||||
|
|
||||||
return &BIP85DRNG{
|
return &DRNG{
|
||||||
shake: shake,
|
shake: shake,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read implements the io.Reader interface
|
// Read implements the io.Reader interface
|
||||||
func (d *BIP85DRNG) Read(p []byte) (n int, err error) {
|
func (d *DRNG) Read(p []byte) (n int, err error) {
|
||||||
return d.shake.Read(p)
|
return d.shake.Read(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +165,7 @@ func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.Ex
|
|||||||
|
|
||||||
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
||||||
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_BIP39, language, words, index)
|
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, AppBIP39, language, words, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,17 +173,26 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine how many bits of entropy to use based on the words
|
// Determine how many bits of entropy to use based on the words
|
||||||
|
// BIP39 defines specific word counts and their corresponding entropy bits
|
||||||
|
const (
|
||||||
|
words12 = 12 // 128 bits of entropy
|
||||||
|
words15 = 15 // 160 bits of entropy
|
||||||
|
words18 = 18 // 192 bits of entropy
|
||||||
|
words21 = 21 // 224 bits of entropy
|
||||||
|
words24 = 24 // 256 bits of entropy
|
||||||
|
)
|
||||||
|
|
||||||
var bits int
|
var bits int
|
||||||
switch words {
|
switch words {
|
||||||
case 12:
|
case words12:
|
||||||
bits = 128
|
bits = 128
|
||||||
case 15:
|
case words15:
|
||||||
bits = 160
|
bits = 160
|
||||||
case 18:
|
case words18:
|
||||||
bits = 192
|
bits = 192
|
||||||
case 21:
|
case words21:
|
||||||
bits = 224
|
bits = 224
|
||||||
case 24:
|
case words24:
|
||||||
bits = 256
|
bits = 256
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
|
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
|
||||||
@@ -193,7 +206,7 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
|||||||
|
|
||||||
// DeriveWIFKey derives a private key in WIF format
|
// DeriveWIFKey derives a private key in WIF format
|
||||||
func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, error) {
|
func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, error) {
|
||||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, APP_HD_WIF, index)
|
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppHDWIF, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,7 +228,7 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
|||||||
|
|
||||||
// DeriveXPRV derives an extended private key (XPRV)
|
// DeriveXPRV derives an extended private key (XPRV)
|
||||||
func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) {
|
func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) {
|
||||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, APP_XPRV, index)
|
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,6 +279,7 @@ func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.Ex
|
|||||||
func doubleSHA256(data []byte) []byte {
|
func doubleSHA256(data []byte) []byte {
|
||||||
hash1 := sha256.Sum256(data)
|
hash1 := sha256.Sum256(data)
|
||||||
hash2 := sha256.Sum256(hash1[:])
|
hash2 := sha256.Sum256(hash1[:])
|
||||||
|
|
||||||
return hash2[:]
|
return hash2[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +322,7 @@ func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
encodedStr = strings.TrimRight(encodedStr, "=")
|
encodedStr = strings.TrimRight(encodedStr, "=")
|
||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length
|
||||||
if uint32(len(encodedStr)) < pwdLen {
|
if len(encodedStr) < int(pwdLen) {
|
||||||
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +335,7 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
return "", fmt.Errorf("pwdLen must be between 10 and 80")
|
return "", fmt.Errorf("pwdLen must be between 10 and 80")
|
||||||
}
|
}
|
||||||
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_PWD85, pwdLen, index)
|
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, AppPWD85, pwdLen, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -332,7 +346,7 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
encoded := encodeBase85WithRFC1924Charset(entropy)
|
encoded := encodeBase85WithRFC1924Charset(entropy)
|
||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length
|
||||||
if uint32(len(encoded)) < pwdLen {
|
if len(encoded) < int(pwdLen) {
|
||||||
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,24 +358,30 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
|
|||||||
// RFC1924 character set
|
// RFC1924 character set
|
||||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||||
|
|
||||||
|
const (
|
||||||
|
base85ChunkSize = 4 // Process 4 bytes at a time
|
||||||
|
base85DigitCount = 5 // Each chunk produces 5 digits
|
||||||
|
base85Base = 85 // Base85 encoding uses base 85
|
||||||
|
)
|
||||||
|
|
||||||
// Pad data to multiple of 4
|
// Pad data to multiple of 4
|
||||||
padded := make([]byte, ((len(data)+3)/4)*4)
|
padded := make([]byte, ((len(data)+base85ChunkSize-1)/base85ChunkSize)*base85ChunkSize)
|
||||||
copy(padded, data)
|
copy(padded, data)
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
buf.Grow(len(padded) * 5 / 4) // Each 4 bytes becomes 5 Base85 characters
|
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
|
||||||
|
|
||||||
// Process in 4-byte chunks
|
// Process in 4-byte chunks
|
||||||
for i := 0; i < len(padded); i += 4 {
|
for i := 0; i < len(padded); i += base85ChunkSize {
|
||||||
// Convert 4 bytes to uint32 (big-endian)
|
// Convert 4 bytes to uint32 (big-endian)
|
||||||
chunk := binary.BigEndian.Uint32(padded[i : i+4])
|
chunk := binary.BigEndian.Uint32(padded[i : i+base85ChunkSize])
|
||||||
|
|
||||||
// Convert to 5 base-85 digits
|
// Convert to 5 base-85 digits
|
||||||
digits := make([]byte, 5)
|
digits := make([]byte, base85DigitCount)
|
||||||
for j := 4; j >= 0; j-- {
|
for j := base85DigitCount - 1; j >= 0; j-- {
|
||||||
idx := chunk % 85
|
idx := chunk % base85Base
|
||||||
digits[j] = charset[idx]
|
digits[j] = charset[idx]
|
||||||
chunk /= 85
|
chunk /= base85Base
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.Write(digits)
|
buf.Write(digits)
|
||||||
|
|||||||
Reference in New Issue
Block a user