Compare commits
3 Commits
main
...
cc53469f90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc53469f90 | ||
| 9ab960565e | |||
| 9d238a03af |
@@ -17,4 +17,5 @@ coverage.out
|
||||
.claude/
|
||||
|
||||
# Local settings
|
||||
.golangci.yml
|
||||
.claude/settings.local.json
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -1,9 +0,0 @@
|
||||
name: check
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-28
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: script/cibuild
|
||||
144
.golangci.yml
144
.golangci.yml
@@ -1,34 +1,128 @@
|
||||
version: "2"
|
||||
|
||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||
# linters.settings, not top-level linters-settings) so that the
|
||||
# thresholds below are actually applied by golangci-lint >= v2.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
go: "1.24"
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
enable:
|
||||
# Additional linters requested
|
||||
- testifylint # Checks usage of github.com/stretchr/testify
|
||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
||||
- tagliatelle # Checks the struct tags
|
||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
||||
- nestif # Reports deeply nested if statements
|
||||
- mnd # An analyzer to detect magic numbers
|
||||
- lll # Reports long lines
|
||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||
- gochecknoglobals # Check that no global variables exist
|
||||
|
||||
# Default/existing linters that are commonly useful
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- ineffassign
|
||||
- misspell
|
||||
- revive
|
||||
- gosec
|
||||
- unconvert
|
||||
- unparam
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 120
|
||||
|
||||
mnd:
|
||||
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
|
||||
checks:
|
||||
- argument
|
||||
- case
|
||||
- condition
|
||||
- operation
|
||||
- return
|
||||
- assign
|
||||
ignored-numbers:
|
||||
- '0'
|
||||
- '1'
|
||||
- '2'
|
||||
- '8'
|
||||
- '16'
|
||||
- '40' # GPG fingerprint length
|
||||
- '64'
|
||||
- '128'
|
||||
- '256'
|
||||
- '512'
|
||||
- '1024'
|
||||
- '2048'
|
||||
- '4096'
|
||||
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
|
||||
nlreturn:
|
||||
block-size: 2
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
arguments:
|
||||
- []
|
||||
- []
|
||||
- "upperCaseConst=true"
|
||||
|
||||
tagliatelle:
|
||||
case:
|
||||
rules:
|
||||
json: snake
|
||||
yaml: snake
|
||||
xml: snake
|
||||
bson: snake
|
||||
|
||||
testifylint:
|
||||
enable-all: true
|
||||
|
||||
usetesting: {}
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
- path: ".*_gen\\.go"
|
||||
linters:
|
||||
- lll
|
||||
|
||||
# Exclude unused parameter warnings for cobra command signatures
|
||||
- text: "parameter '(args|cmd)' seems to be unused"
|
||||
linters:
|
||||
- revive
|
||||
|
||||
# Allow ALL_CAPS constant names
|
||||
- text: "don't use ALL_CAPS in Go names"
|
||||
linters:
|
||||
- revive
|
||||
|
||||
# Exclude all linters for internal/macse directory
|
||||
- path: "internal/macse/.*"
|
||||
linters:
|
||||
- errcheck
|
||||
- lll
|
||||
- mnd
|
||||
- nestif
|
||||
- nlreturn
|
||||
- revive
|
||||
- unconvert
|
||||
- govet
|
||||
- staticcheck
|
||||
- unused
|
||||
- ineffassign
|
||||
- misspell
|
||||
- gosec
|
||||
- unparam
|
||||
- testifylint
|
||||
- usetesting
|
||||
- tagliatelle
|
||||
- nilnil
|
||||
- intrange
|
||||
- gochecknoglobals
|
||||
|
||||
56
Dockerfile
56
Dockerfile
@@ -1,46 +1,50 @@
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage — tests and compilation
|
||||
# golang 1.24.13-alpine (2026-03-10)
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
|
||||
# Force BuildKit to run the lint stage
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev make git gnupg
|
||||
# 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 . .
|
||||
|
||||
RUN make test
|
||||
RUN make build
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go
|
||||
|
||||
# Runtime stage
|
||||
# alpine 3.23 (2026-03-10)
|
||||
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates gnupg
|
||||
# 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
|
||||
|
||||
ENTRYPOINT ["secret"]
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["secret"]
|
||||
29
Makefile
29
Makefile
@@ -7,16 +7,8 @@ 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)'
|
||||
|
||||
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
|
||||
|
||||
default: check
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
build: ./secret
|
||||
|
||||
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
|
||||
@@ -25,25 +17,24 @@ build: ./secret
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
test:
|
||||
@script/test
|
||||
test: lint vet
|
||||
go test ./... || go test -v ./...
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
go fmt ./...
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
golangci-lint run --timeout 5m
|
||||
|
||||
check:
|
||||
@script/check
|
||||
check: build test
|
||||
|
||||
# Build Docker container
|
||||
docker:
|
||||
@script/docker
|
||||
docker build -t sneak/secret .
|
||||
|
||||
# Run Docker container interactively
|
||||
docker-run:
|
||||
docker run --rm -it "$$(./script/projectname)"
|
||||
docker run --rm -it sneak/secret
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@@ -51,9 +42,3 @@ clean:
|
||||
|
||||
install: ./secret
|
||||
cp ./secret $(HOME)/bin/secret
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
30
README.md
30
README.md
@@ -486,36 +486,6 @@ go test ./... # Unit tests
|
||||
go test -tags=integration -v ./internal/cli # Integration tests
|
||||
```
|
||||
|
||||
## Entrypoints
|
||||
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call
|
||||
them. We provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
|
||||
module download), idempotently
|
||||
- `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`
|
||||
- `script/projectname` — output the project name (`secret`); used by
|
||||
other scripts such as `script/docker`
|
||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
||||
failure)
|
||||
- `script/lint` — run `golangci-lint`
|
||||
- `script/fmt` — format all Go code (writes)
|
||||
- `script/fmt-check` — check formatting without writing
|
||||
- `script/check` — run `script/test`, `script/lint`, and
|
||||
`script/fmt-check`
|
||||
- `script/docker` — build the Docker image tagged with the project name
|
||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
||||
checks)
|
||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
||||
then `script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook that
|
||||
runs `script/precommit`
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
|
||||
|
||||
408
REPO_POLICIES.md
408
REPO_POLICIES.md
@@ -1,408 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-07-06
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
|
||||
- Repos follow the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
pattern: the implementation of each Makefile target lives in an executable
|
||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||
for development after a fresh clone: runs `bootstrap`, then
|
||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||
scripts are our own extensions to the standard: `script/check` runs
|
||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||
what the git pre-commit hook runs, and it calls `script/check`;
|
||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||
target shims to it); and `script/projectname` (literally that filename) simply
|
||||
outputs the project's name. Scripts that need the name call
|
||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled. Dockerfiles install development
|
||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||
layer stays cached until dependencies change.
|
||||
|
||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||
repos use a multistage build where linting runs in an independent stage based
|
||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||
then declares an explicit dependency on the lint stage via
|
||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||
linting before proceeding to compilation and tests. This ensures lint failures
|
||||
surface in seconds rather than minutes, without blocking on dependency
|
||||
download or compilation in the build stage.
|
||||
|
||||
The standard pattern for a Go repo Dockerfile is:
|
||||
|
||||
```dockerfile
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
WORKDIR /src
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make test
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||
includes both Go and the linter), so there is no need to install the
|
||||
linter separately.
|
||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||
this line, the build stage would not wait for lint to finish and a lint
|
||||
failure might not fail the overall build.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
The lint stage should not depend on the actual build output — it exists to
|
||||
fail fast.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint stage with `apk add`.
|
||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||
build stage, not the lint stage, because they may require compiled
|
||||
artifacts or heavier dependencies.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||
that shims to it.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||
show full output. This keeps CI logs and `docker build` output clean on
|
||||
success (just package/suite summaries) while providing full diagnostic detail
|
||||
on failure (every test case, every assertion). The general shell pattern:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@<test-command> || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
<test-command-with-v>; exit 1; }
|
||||
```
|
||||
|
||||
Go example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -timeout 30s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v ./...; exit 1; }
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@python -m pytest || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
python -m pytest -v; exit 1; }
|
||||
```
|
||||
|
||||
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||
already proved the tests are broken, so the build must not pass even if a
|
||||
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||
for diagnostic output.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
|
||||
- **No build artifacts in version control.** Code-derived data (compiled
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||
should generate these at build time. Notable exception: Go protobuf generated
|
||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||
downloads code but does not execute code generation.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- **HTTP/web services must be hardened for production internet exposure before
|
||||
tagging 1.0.** This means full compliance with security best practices
|
||||
including, without limitation, all of the following:
|
||||
- **Security headers** on every response:
|
||||
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||
and `includeSubDomains`.
|
||||
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||
unavoidable, and document the reason.
|
||||
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||
- `Permissions-Policy` restricting access to browser features the
|
||||
application does not use (camera, microphone, geolocation, etc.).
|
||||
- **Request and response limits:**
|
||||
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||
unbounded input.
|
||||
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||
against slowloris attacks.
|
||||
- `WriteTimeout` on the `http.Server`.
|
||||
- `IdleTimeout` on the `http.Server`.
|
||||
- Per-handler execution time limits via `context.WithTimeout` or
|
||||
chi/stdlib `middleware.Timeout`.
|
||||
- **Authentication and session security:**
|
||||
- Rate limiting on password-based authentication endpoints. API keys are
|
||||
high-entropy and not susceptible to brute force, so they are exempt.
|
||||
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||
authenticated via `Authorization` header (Bearer token, API key) are
|
||||
exempt because the browser does not attach these automatically.
|
||||
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||
MD5, or SHA.
|
||||
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||
`Strict`) attributes.
|
||||
- **Reverse proxy awareness:**
|
||||
- True client IP detection when behind a reverse proxy
|
||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||
forwarded headers only from a configured set of trusted proxy
|
||||
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||
- **CORS:**
|
||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||
only for public, unauthenticated read-only APIs.
|
||||
- **Error handling:**
|
||||
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||
or other implementation details to the client. Return generic error
|
||||
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||
- **TLS:**
|
||||
- Services never terminate TLS directly. They are always deployed behind
|
||||
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||
set by the application so that the browser enforces HTTPS end-to-end.
|
||||
|
||||
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||
hardening measure exists for HTTP services and is not listed here, it is
|
||||
still expected. When in doubt, harden.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard (with that link), then documents each provided `script/`
|
||||
entrypoint and its purpose.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`)
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
247
TODO.md
247
TODO.md
@@ -1,120 +1,147 @@
|
||||
# Workflow
|
||||
# TODO for 1.0 Release
|
||||
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
This document outlines the bugs, issues, and improvements that need to be
|
||||
addressed before the 1.0 release of the secret manager. Items are
|
||||
prioritized from most critical (top) to least critical (bottom).
|
||||
|
||||
# Status
|
||||
## CRITICAL BLOCKERS FOR 1.0 RELEASE
|
||||
|
||||
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
|
||||
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
|
||||
### Command Injection Vulnerabilities
|
||||
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
|
||||
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
|
||||
|
||||
# Next Step
|
||||
### 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
|
||||
|
||||
Bring the repo into policy compliance in one commit:
|
||||
### 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
|
||||
|
||||
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
|
||||
docker already exist).
|
||||
- Add REPO_POLICIES.md and .editorconfig.
|
||||
- Add .gitea/workflows/check.yml running make check.
|
||||
- Verify Dockerfile base images are pinned by sha256.
|
||||
### 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
|
||||
|
||||
# Completed Steps
|
||||
### 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
|
||||
|
||||
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
||||
`.golangci.yml` (all linters enabled minus the standard disable
|
||||
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
|
||||
image to the tagged v2.12.2 Debian digest; fixed all ~1550 new
|
||||
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
|
||||
blank lines, sentinel errors for `err113`, `t.Parallel()` where
|
||||
safe, `_test` package conversions, complexity/`dupl` helper
|
||||
extraction) on branch `golangci-v2.12.2`. Reworked after review:
|
||||
the `err113` sentinels in `internal/vault`, `internal/secret`,
|
||||
`internal/cli` and `pkg/bip85` were reshaped so every composed
|
||||
error message is byte-identical to `main`, and
|
||||
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||
emitting a fabricated fallback ID.
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||
protection, plus review fixes (stub panics, derivation index, tests,
|
||||
README) on branch secure-enclave-unlocker.
|
||||
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
|
||||
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
|
||||
missing metadata, allow uppercase secret names, fix hardcoded
|
||||
derivation index, validate names in GetSecretVersion against path
|
||||
traversal, return errors instead of panicking, add Warn() on silent
|
||||
anomalies.
|
||||
- Memory security hardening: LockedBuffer used through encrypt/decrypt
|
||||
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
|
||||
bare-[]byte APIs removed.
|
||||
- Per-secret keypair architecture, vault package refactor, versioning
|
||||
with --version, comprehensive test suite with in-memory filesystem.
|
||||
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
|
||||
- Renamed SEP unlocker to Keychain, reorganized import commands.
|
||||
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
|
||||
CLI).
|
||||
## CRITICAL MEMORY SECURITY ISSUES
|
||||
|
||||
# Future Steps
|
||||
### 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
|
||||
|
||||
- Compliance (after Next Step lands): keep main green under the new
|
||||
.gitea workflow; run make check before every merge.
|
||||
- Implement version-number shell completion for the second arg of
|
||||
`secret version promote` and `secret version rm`
|
||||
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||
- Cover mnemonic-vs-xprv identity consistency in
|
||||
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
|
||||
in-code FIXME removed for godox).
|
||||
- Darwin-gated files (`internal/secret/keychainunlocker.go`,
|
||||
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
|
||||
tests) are not linted on the Linux CI runner and still contain lines
|
||||
over the new 88-column limit; they will surface if lint ever runs on
|
||||
macOS.
|
||||
- Merge secure-enclave-unlocker to main once review is done.
|
||||
- 1.0 critical security blockers (from repo TODO.md):
|
||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
||||
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
||||
security command (keychainunlocker.go:472-476).
|
||||
- Memory security: KeychainData stores AgePrivKeyPassphrase as a
|
||||
plain string (keychainunlocker.go:342,393-396); age identity
|
||||
.String() creates unprotected copies (keychainunlocker.go:356,
|
||||
pgpunlocker.go:256, version.go:155); age secret key held in a
|
||||
plain string in cli/crypto.go:86,91,113; private keys exposed via
|
||||
buffer.Bytes() to GPGEncryptFunc and EncryptWithPassphrase.
|
||||
- Race conditions: no file locking in vault/secrets.go:142-176;
|
||||
non-atomic writes can leave the vault inconsistent.
|
||||
- Input validation: dots in secret names risk path traversal
|
||||
(vault/secrets.go:75-99); no maximum secret size (DoS).
|
||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
||||
- High priority:
|
||||
- Return errors instead of panicking on corrupted metadata
|
||||
(pgpunlocker.go:116, keychainunlocker.go:141).
|
||||
- Secure temporary file handling and cleanup.
|
||||
- Print cobra usage only for argument errors, not internal
|
||||
failures.
|
||||
- Initialize a default unlock key at vault creation.
|
||||
- Confirmation prompts for destructive operations (keys rm, vault
|
||||
deletion).
|
||||
- Add secret rm and vault deletion commands.
|
||||
- Medium priority:
|
||||
- Standardize error messages; stop leaking internals.
|
||||
- Graceful handling of corrupted or missing key files with recovery
|
||||
suggestions.
|
||||
- Validate GPG key existence before creating PGP unlock keys.
|
||||
- Split oversized CLI functions.
|
||||
- Document env var security (SB_UNLOCK_PASSPHRASE,
|
||||
SB_SECRET_MNEMONIC); clear after use.
|
||||
- mlock/munlock for sensitive allocations.
|
||||
- Cleanups: read statedir from environment or default instead of
|
||||
passing it around.
|
||||
- Enhancements: help examples, shell completion, colored output,
|
||||
--quiet flag, name suggestions on miss, audit logging, hardware
|
||||
integration tests (Keychain, GPG), naming consistency, vault
|
||||
export/import, batch operations, search, secret metadata
|
||||
(descriptions, tags).
|
||||
### 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
|
||||
environment or default.
|
||||
|
||||
## HIGH PRIORITY SECURITY ISSUES
|
||||
|
||||
- [ ] **4. Application crashes on corrupted metadata**: Code panics instead
|
||||
of returning errors when metadata is corrupt, causing denial of service.
|
||||
Found in pgpunlocker.go:116 and keychainunlocker.go:141.
|
||||
|
||||
- [ ] **5. Insufficient input validation**: Secret names allow potentially
|
||||
dangerous patterns including dots that could enable path traversal attacks
|
||||
(vault/secrets.go:70-93).
|
||||
|
||||
- [ ] **6. Race conditions in file operations**: Multiple concurrent
|
||||
operations could corrupt the vault state due to lack of file locking
|
||||
mechanisms.
|
||||
|
||||
- [ ] **7. Insecure temporary file handling**: Temporary files containing
|
||||
sensitive data may not be properly cleaned up or secured.
|
||||
|
||||
## HIGH PRIORITY FUNCTIONALITY ISSUES
|
||||
|
||||
- [ ] **8. Inappropriate Cobra usage printing**: Commands currently print
|
||||
usage information for all errors, including internal program failures.
|
||||
Usage should only be printed when the user provides incorrect arguments or
|
||||
invalid commands.
|
||||
|
||||
- [ ] **9. Missing current unlock key initialization**: When creating
|
||||
vaults, no default unlock key is selected, which can cause operations to
|
||||
fail.
|
||||
|
||||
- [ ] **10. Add confirmation prompts for destructive operations**:
|
||||
Operations like `keys rm` and vault deletion should require confirmation.
|
||||
|
||||
- [ ] **11. No secret deletion command**: Missing `secret rm <secret-name>`
|
||||
functionality.
|
||||
|
||||
- [ ] **12. Missing vault deletion command**: No way to delete vaults that
|
||||
are no longer needed.
|
||||
|
||||
## MEDIUM PRIORITY ISSUES
|
||||
|
||||
- [ ] **13. Inconsistent error messages**: Error messages need
|
||||
standardization and should be user-friendly. Many errors currently expose
|
||||
internal implementation details.
|
||||
|
||||
- [ ] **14. No graceful handling of corrupted state**: If key files are
|
||||
corrupted or missing, the tool should provide clear error messages and
|
||||
recovery suggestions.
|
||||
|
||||
- [ ] **15. No validation of GPG key existence**: Should verify the
|
||||
specified GPG key exists before creating PGP unlock keys.
|
||||
|
||||
- [ ] **16. Better separation of concerns**: Some functions in CLI do too
|
||||
much and should be split.
|
||||
|
||||
- [ ] **17. Environment variable security**: Sensitive data read from
|
||||
environment variables (SB_UNLOCK_PASSPHRASE, SB_SECRET_MNEMONIC) without
|
||||
proper clearing. Document security implications.
|
||||
|
||||
- [ ] **18. No secure memory allocation**: No use of mlock/munlock to
|
||||
prevent sensitive data from being swapped to disk.
|
||||
|
||||
## LOWER PRIORITY ENHANCEMENTS
|
||||
|
||||
- [ ] **19. Add `--help` examples**: Command help should include practical examples for each operation.
|
||||
|
||||
- [ ] **20. Add shell completion**: Bash/Zsh completion for commands and secret names.
|
||||
|
||||
- [ ] **21. Colored output**: Use colors to improve readability of lists and error messages.
|
||||
|
||||
- [ ] **22. Add `--quiet` flag**: Option to suppress non-essential output.
|
||||
|
||||
- [ ] **23. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
|
||||
|
||||
- [ ] **24. Audit logging**: Log all secret access and modifications for security auditing.
|
||||
|
||||
- [ ] **25. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
|
||||
|
||||
- [ ] **26. Consistent naming conventions**: Some variables and functions use inconsistent naming patterns.
|
||||
|
||||
- [ ] **27. Export/import functionality**: Add ability to export/import entire vaults, not just individual secrets.
|
||||
|
||||
- [ ] **28. Batch operations**: Add commands to process multiple secrets at once.
|
||||
|
||||
- [ ] **29. Search functionality**: Add ability to search secret names and potentially contents.
|
||||
|
||||
- [ ] **30. Secret metadata**: Add support for descriptions, tags, or other metadata with secrets.
|
||||
|
||||
## COMPLETED ITEMS ✓
|
||||
|
||||
- [x] **Missing secret history/versioning**: ✓ Implemented - versioning system exists with --version flag support
|
||||
- [x] **XDG compliance on Linux**: ✓ Implemented - uses os.UserConfigDir() which respects XDG_CONFIG_HOME
|
||||
- [x] **Consistent interface implementation**: ✓ Implemented - Unlocker interface is well-defined and consistently implemented
|
||||
|
||||
@@ -19,7 +19,6 @@ type Instance struct {
|
||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||
func NewCLIInstance() (*Instance, error) {
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||
@@ -31,8 +30,7 @@ func NewCLIInstance() (*Instance, error) {
|
||||
}, 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, error) {
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
@@ -45,8 +43,7 @@ func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||
}, 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)
|
||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
@@ -70,6 +67,6 @@ func (cli *Instance) GetStateDir() string {
|
||||
}
|
||||
|
||||
// Print outputs to the command's configured output writer
|
||||
func (cli *Instance) Print(a ...any) (int, error) {
|
||||
func (cli *Instance) Print(a ...interface{}) (n int, err error) {
|
||||
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
package cli_test
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestCLIInstanceStateDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test the CLI instance state directory functionality
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test state directory
|
||||
testStateDir := "/test-state-dir"
|
||||
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
got := instance.GetStateDir()
|
||||
if got != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
|
||||
if cli.GetStateDir() != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestCLIInstanceWithFs(t *testing.T) {
|
||||
// Test creating CLI instance with custom filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
instance, err := 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
|
||||
stateDir := instance.GetStateDir()
|
||||
stateDir := cli.GetStateDir()
|
||||
if stateDir == "" {
|
||||
t.Error("Expected non-empty state directory")
|
||||
}
|
||||
@@ -54,7 +48,6 @@ func TestDetermineStateDir(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stateDir != testEnvDir {
|
||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||
}
|
||||
@@ -62,15 +55,12 @@ func TestDetermineStateDir(t *testing.T) {
|
||||
// Test with custom config dir
|
||||
_ = os.Unsetenv(secret.EnvStateDir)
|
||||
customConfigDir := "/custom-config"
|
||||
|
||||
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// errUnsupportedShell is returned for unknown shell completion targets
|
||||
var errUnsupportedShell = errors.New("unsupported shell type")
|
||||
|
||||
func newCompletionCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
@@ -59,7 +55,7 @@ PowerShell:
|
||||
case "powershell":
|
||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
|
||||
return fmt.Errorf("unsupported shell type: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -10,14 +11,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// getSecretNamesCompletionFunc returns a completion function that provides
|
||||
// secret names
|
||||
// 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) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
@@ -32,7 +30,6 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
|
||||
// Filter secrets based on what user has typed
|
||||
var completions []string
|
||||
|
||||
for _, secret := range secrets {
|
||||
if strings.HasPrefix(secret, toComplete) {
|
||||
completions = append(completions, secret)
|
||||
@@ -43,14 +40,11 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||
// unlocker IDs
|
||||
// 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) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
@@ -72,24 +66,61 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
// Collect unlocker IDs
|
||||
var completions []string
|
||||
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
for _, metadata := range unlockerMetadataList {
|
||||
// Get the actual unlocker ID by creating the unlocker instance
|
||||
id, err := findUnlockerIDByMetadata(
|
||||
fs, unlockersDir, metadata, false,
|
||||
)
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
files, err := afero.ReadDir(fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn(
|
||||
"Could not read unlockers directory during completion, "+
|
||||
"skipping unlocker",
|
||||
"unlockers_dir", unlockersDir, "error", err)
|
||||
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if id != "" && strings.HasPrefix(id, toComplete) {
|
||||
completions = append(completions, id)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,21 +128,17 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// getVaultNamesCompletionFunc returns a completion function that provides
|
||||
// vault names
|
||||
// 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) {
|
||||
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)
|
||||
@@ -122,81 +149,57 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||
// colon is present in the input
|
||||
func completeVaultQualifiedSecrets(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// Complete secret names for the specified vault
|
||||
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
|
||||
}
|
||||
|
||||
// completeUnqualifiedVaultSecrets completes vault names (with a ":"
|
||||
// suffix) and secrets from the current vault
|
||||
func completeUnqualifiedVaultSecrets(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// 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)
|
||||
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err == nil {
|
||||
secrets, err := currentVlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, toComplete) {
|
||||
completions = append(completions, secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
// getVaultSecretCompletionFunc returns a completion function for the
|
||||
// vault:secret format. It completes vault names with ":" suffix, and
|
||||
// after ":" it completes secrets from that vault.
|
||||
// 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) {
|
||||
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, ":") {
|
||||
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoFileComp
|
||||
// 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
|
||||
}
|
||||
|
||||
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoSpace
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,22 +12,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Sentinel errors for encrypt/decrypt operations
|
||||
var (
|
||||
errNotAgeSecretKey = errors.New(
|
||||
"does not contain a valid age secret key")
|
||||
errSecretDoesNotExist = errors.New("does not exist")
|
||||
)
|
||||
|
||||
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
||||
func newCryptoCmd(
|
||||
use, short, long string,
|
||||
run func(cli *Instance, secretName, inputFile, outputFile string) error,
|
||||
) *cobra.Command {
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Use: "encrypt <secret-name>",
|
||||
Short: "Encrypt data using an age secret key stored in a secret",
|
||||
Long: `Encrypt data using an age secret key. If the secret doesn't exist, a new age key is generated and stored.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
@@ -38,10 +26,9 @@ func newCryptoCmd(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return run(cli, args[0], inputFile, outputFile)
|
||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,73 +38,30 @@ func newCryptoCmd(
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
return newCryptoCmd(
|
||||
"encrypt <secret-name>",
|
||||
"Encrypt data using an age secret key stored in a secret",
|
||||
"Encrypt data using an age secret key. If the secret doesn't "+
|
||||
"exist, a new age key is generated and stored.",
|
||||
(*Instance).Encrypt,
|
||||
)
|
||||
}
|
||||
|
||||
func newDecryptCmd() *cobra.Command {
|
||||
return newCryptoCmd(
|
||||
"decrypt <secret-name>",
|
||||
"Decrypt data using an age secret key stored in a secret",
|
||||
"Decrypt data using an age secret key stored in the specified secret.",
|
||||
(*Instance).Decrypt,
|
||||
)
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt <secret-name>",
|
||||
Short: "Decrypt data using an age secret key stored in a secret",
|
||||
Long: `Decrypt data using an age secret key stored in the specified secret.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
||||
// for the named secret, generating and storing a new key if the secret
|
||||
// does not exist. The caller must destroy the returned buffer.
|
||||
func (cli *Instance) resolveEncryptionKey(
|
||||
vlt *vault.Vault, secretName string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
cli.cmd = cmd
|
||||
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
|
||||
// Store the generated key directly in a secure buffer
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
secureBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// Secret exists, get the age secret key from it
|
||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
secretBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
}
|
||||
|
||||
return secretBuffer, nil
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using an age secret key stored in a secret
|
||||
@@ -128,15 +72,55 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get or create the age secret key for this secret
|
||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyBuffer.Destroy()
|
||||
var ageSecretKey string
|
||||
|
||||
// Parse the secret key
|
||||
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
// Store the generated key directly in a secure buffer
|
||||
identityStr := identity.String()
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identityStr))
|
||||
defer secureBuffer.Destroy()
|
||||
|
||||
// Set ageSecretKey for later use (we need it for encryption)
|
||||
ageSecretKey = identityStr
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
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 using secure buffer
|
||||
finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
|
||||
defer finalSecureBuffer.Destroy()
|
||||
|
||||
identity, err := age.ParseX25519Identity(finalSecureBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
@@ -146,27 +130,23 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -176,13 +156,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(encryptor, input)
|
||||
if err != nil {
|
||||
if _, err := io.Copy(encryptor, input); err != nil {
|
||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
err = encryptor.Close()
|
||||
if err != nil {
|
||||
if err := encryptor.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
@@ -199,18 +177,26 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
||||
return fmt.Errorf("secret '%s' does not exist", secretName)
|
||||
}
|
||||
|
||||
// Get the age secret key from the secret
|
||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||
var secretBuffer *memguard.LockedBuffer
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
secretBuffer, err = secretObj.GetValue(nil)
|
||||
} else {
|
||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||
}
|
||||
secretBuffer, err = secretObj.GetValue(unlocker)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
@@ -218,7 +204,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
|
||||
// Parse the age secret key to get the identity
|
||||
@@ -229,27 +215,23 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -259,27 +241,22 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(output, decryptor)
|
||||
if err != nil {
|
||||
if _, err := io.Copy(output, decryptor); err != nil {
|
||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := age.ParseX25519Identity(key)
|
||||
|
||||
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) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
@@ -18,16 +17,6 @@ const (
|
||||
mnemonicEntropyBits = 128
|
||||
)
|
||||
|
||||
// Sentinel errors for secret generation
|
||||
var (
|
||||
errLengthTooSmall = errors.New("length must be at least 1")
|
||||
errLengthNotPositive = errors.New("length must be positive")
|
||||
errMnemonicTypeNotSupported = errors.New(
|
||||
"mnemonic type not supported for secret generation, " +
|
||||
"use 'secret generate mnemonic' instead")
|
||||
errUnsupportedSecretType = errors.New("unsupported type")
|
||||
)
|
||||
|
||||
func newGenerateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
@@ -63,9 +52,8 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret <name>",
|
||||
Short: "Generate a random secret and store it in the vault",
|
||||
Long: `Generate a cryptographically secure random secret and ` +
|
||||
`store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Generate a cryptographically secure random secret and store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
length, _ := cmd.Flags().GetInt("length")
|
||||
secretType, _ := cmd.Flags().GetString("type")
|
||||
@@ -80,10 +68,8 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
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().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().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
|
||||
return cmd
|
||||
@@ -112,8 +98,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
|
||||
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
|
||||
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, " • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||
@@ -131,13 +116,11 @@ func (cli *Instance) GenerateSecret(
|
||||
force bool,
|
||||
) error {
|
||||
if length < 1 {
|
||||
return errLengthTooSmall
|
||||
return fmt.Errorf("length must be at least 1")
|
||||
}
|
||||
|
||||
var (
|
||||
secretValue string
|
||||
err error
|
||||
)
|
||||
var secretValue string
|
||||
var err error
|
||||
|
||||
switch secretType {
|
||||
case "base58":
|
||||
@@ -145,10 +128,9 @@ func (cli *Instance) GenerateSecret(
|
||||
case "alnum":
|
||||
secretValue, err = generateRandomAlnum(length)
|
||||
case "mnemonic":
|
||||
return errMnemonicTypeNotSupported
|
||||
return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
|
||||
default:
|
||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||
errUnsupportedSecretType, secretType)
|
||||
return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -165,13 +147,11 @@ func (cli *Instance) GenerateSecret(
|
||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, force)
|
||||
if err != nil {
|
||||
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -190,11 +170,10 @@ func generateRandomAlnum(length int) (string, error) {
|
||||
return generateRandomString(length, alnumChars)
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string of the specified length
|
||||
// using the given character set
|
||||
// generateRandomString generates a random string of the specified length using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", errLengthNotPositive
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
@@ -205,7 +184,6 @@ func generateRandomString(length int, charset string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Version info - these are set at build time
|
||||
var (
|
||||
var ( //nolint:gochecknoglobals // Set at build time
|
||||
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
||||
)
|
||||
@@ -35,8 +35,8 @@ type InfoOutput struct {
|
||||
NumVaults int `json:"numVaults"`
|
||||
NumSecrets int `json:"numSecrets"`
|
||||
TotalSize int64 `json:"totalSizeBytes"`
|
||||
OldestSecret time.Time `json:"oldestSecret"`
|
||||
LatestSecret time.Time `json:"latestSecret"`
|
||||
OldestSecret time.Time `json:"oldestSecret,omitempty"`
|
||||
LatestSecret time.Time `json:"latestSecret,omitempty"`
|
||||
}
|
||||
|
||||
// newInfoCmd returns the info command
|
||||
@@ -51,8 +51,7 @@ func newInfoCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Display system information",
|
||||
Long: "Display information about the secret system including " +
|
||||
"version, vault statistics, and storage usage",
|
||||
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)
|
||||
},
|
||||
@@ -82,7 +81,6 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
// Count vaults
|
||||
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||
|
||||
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||
if err == nil {
|
||||
for _, entry := range vaultEntries {
|
||||
@@ -94,15 +92,12 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
// Gather statistics from all vaults
|
||||
if info.NumVaults > 0 {
|
||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
|
||||
cli.fs, vaultsDir)
|
||||
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
|
||||
}
|
||||
@@ -149,24 +144,19 @@ func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
||||
_, _ = 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 {
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
|
||||
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||
//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"))
|
||||
_, _ = 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.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
@@ -8,115 +8,81 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// vaultStats accumulates statistics while walking vault directories
|
||||
type vaultStats struct {
|
||||
totalSecrets int
|
||||
totalSize int64
|
||||
oldestTime time.Time
|
||||
latestTime time.Time
|
||||
}
|
||||
|
||||
// addVersion accumulates size and timestamp info for one version directory
|
||||
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||
// Add size of encrypted data
|
||||
dataPath := filepath.Join(versionPath, "data.age")
|
||||
|
||||
stat, err := fs.Stat(dataPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Add size of metadata
|
||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||
|
||||
stat, err = fs.Stat(metaPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
stat, err = fs.Stat(versionPath)
|
||||
if err == nil {
|
||||
modTime := stat.ModTime()
|
||||
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||
s.oldestTime = modTime
|
||||
}
|
||||
|
||||
if s.latestTime.IsZero() || modTime.After(s.latestTime) {
|
||||
s.latestTime = modTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addSecret accumulates stats for one secret directory
|
||||
func (s *vaultStats) addSecret(fs afero.Fs, secretsPath, secretName string) {
|
||||
s.totalSecrets++
|
||||
secretPath := filepath.Join(secretsPath, secretName)
|
||||
|
||||
// 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", secretName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, versionEntry := range versionEntries {
|
||||
if !versionEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
// addVault accumulates stats for one vault directory
|
||||
func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
|
||||
vaultPath := filepath.Join(vaultsDir, vaultName)
|
||||
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", vaultName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, secretEntry := range secretEntries {
|
||||
if !secretEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addSecret(fs, secretsPath, secretEntry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// gatherVaultStats collects statistics from all vaults, returning the
|
||||
// total secret count, total size, and oldest/latest secret timestamps
|
||||
// gatherVaultStats collects statistics from all vaults
|
||||
func gatherVaultStats(
|
||||
fs afero.Fs,
|
||||
vaultsDir string,
|
||||
) (int, int64, time.Time, time.Time, error) {
|
||||
) (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
|
||||
}
|
||||
|
||||
var stats vaultStats
|
||||
|
||||
for _, vaultEntry := range vaultEntries {
|
||||
if !vaultEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.addVault(fs, vaultsDir, vaultEntry.Name())
|
||||
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 stats.totalSecrets, stats.totalSize,
|
||||
stats.oldestTime, stats.latestTime, nil
|
||||
return totalSecrets, totalSize, oldestTime, latestTime, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
@@ -18,17 +16,13 @@ import (
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||
var errPassphraseMismatch = errors.New("passphrases do not match")
|
||||
|
||||
// NewInitCmd creates the init command
|
||||
func NewInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize the secrets manager",
|
||||
Long: `Create the necessary directory structure for storing ` +
|
||||
`secrets and generate encryption keys.`,
|
||||
RunE: RunInit,
|
||||
Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`,
|
||||
RunE: RunInit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,67 +36,6 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
||||
return cli.Init(cmd)
|
||||
}
|
||||
|
||||
// promptMnemonic reads the mnemonic from the environment or interactively.
|
||||
// The returned cleanup function must be deferred by the caller.
|
||||
func promptMnemonic() (string, func(), error) {
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
|
||||
return envMnemonic, func() {}, nil
|
||||
}
|
||||
|
||||
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 "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
|
||||
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// setupDefaultVault creates the default vault and derives its long-term
|
||||
// identity from the mnemonic
|
||||
func (cli *Instance) setupDefaultVault(
|
||||
stateDir, mnemonicStr string,
|
||||
) (*vault.Vault, *age.X25519Identity, error) {
|
||||
// Create the default vault - it will handle key derivation internally
|
||||
secret.Debug("Creating default vault")
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
|
||||
return nil, nil, 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 {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
|
||||
return nil, nil, fmt.Errorf(
|
||||
"failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return vlt, ltIdentity, nil
|
||||
}
|
||||
|
||||
// Init initializes the secret manager
|
||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
secret.Debug("Starting secret manager initialization")
|
||||
@@ -111,8 +44,7 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
stateDir := cli.GetStateDir()
|
||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||
|
||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
@@ -123,55 +55,100 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Prompt for mnemonic
|
||||
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
defer cleanupMnemonic()
|
||||
|
||||
if mnemonicStr == "" {
|
||||
secret.Debug("Empty mnemonic provided")
|
||||
|
||||
return errMnemonicEmpty
|
||||
return fmt.Errorf("mnemonic cannot be empty")
|
||||
}
|
||||
|
||||
// Validate the mnemonic using BIP39
|
||||
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) {
|
||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
||||
errInvalidMnemonicPhrase)
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||
defer restoreMnemonicEnv()
|
||||
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 default vault and derive its long-term key
|
||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
||||
// Create the default vault - it will handle key derivation internally
|
||||
secret.Debug("Creating default vault")
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
if err != nil {
|
||||
return err
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
|
||||
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 {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Prompt for passphrase for unlocker
|
||||
passphraseBuffer, err := resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
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 {
|
||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
||||
|
||||
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 {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
@@ -217,7 +194,7 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
passphraseBuffer1.Destroy()
|
||||
passphraseBuffer2.Destroy()
|
||||
|
||||
return nil, errPassphraseMismatch
|
||||
return nil, fmt.Errorf("passphrases do not match")
|
||||
}
|
||||
|
||||
// Clean up the second buffer, we'll return the first
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,21 +10,17 @@ import (
|
||||
// Entry is the entry point for the secret CLI application
|
||||
func Entry() {
|
||||
cmd := newRootCmd()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
secret.Debug("newRootCmd starting")
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret",
|
||||
Short: "A simple secrets manager",
|
||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||
`information securely.`,
|
||||
Long: `A simple secrets manager to store and retrieve sensitive information securely.`,
|
||||
// Ensure usage is shown after errors
|
||||
SilenceUsage: false,
|
||||
SilenceErrors: false,
|
||||
|
||||
@@ -2,12 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
@@ -22,36 +20,12 @@ const (
|
||||
vaultSecretSeparator = ":"
|
||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||
vaultSecretParts = 2
|
||||
|
||||
// initialBufferSize is the starting size for secret read buffers (4KB)
|
||||
initialBufferSize = 4 * 1024
|
||||
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
||||
maxSecretSize = 100 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Sentinel errors for secret operations
|
||||
var (
|
||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||
errSecretFileTooLarge = errors.New(
|
||||
"secret file too large: exceeds 100MB limit")
|
||||
errSecretNotFound = errors.New("not found")
|
||||
errSecretExistsNoForce = errors.New(
|
||||
"already exists (use --force to overwrite)")
|
||||
errVaultDoesNotExist = errors.New("does not exist")
|
||||
errCrossVaultSourceUnqualified = errors.New(
|
||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||
)
|
||||
|
||||
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
// 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) (string, string, bool) {
|
||||
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
|
||||
@@ -68,7 +42,6 @@ func newAddCmd() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
secret.Debug("Add command RunE starting", "secret_name", args[0])
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
secret.Debug("Got force flag", "force", force)
|
||||
|
||||
@@ -76,9 +49,7 @@ func newAddCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd // Set the command for stdin access
|
||||
|
||||
secret.Debug("Created CLI instance, calling AddSecret")
|
||||
|
||||
return cli.AddSecret(args[0], force)
|
||||
@@ -95,7 +66,6 @@ func newGetCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <secret-name>",
|
||||
Short: "Retrieve a secret from the vault",
|
||||
@@ -103,7 +73,6 @@ func newGetCmd() *cobra.Command {
|
||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
@@ -123,9 +92,8 @@ func newListCmd() *cobra.Command {
|
||||
Use: "list [filter]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List all secrets in the current vault",
|
||||
Long: `List all secrets in the current vault. Optionally filter ` +
|
||||
`by substring match in secret name.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Long: `List all secrets in the current vault. Optionally filter by substring match in secret name.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
||||
@@ -154,9 +122,8 @@ func newImportCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import <secret-name>",
|
||||
Short: "Import a secret from a file",
|
||||
Long: `Import a secret from a file and store it in the current ` +
|
||||
`vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Import a secret from a file and store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
sourceFile, _ := cmd.Flags().GetString("source")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
@@ -182,13 +149,12 @@ func newRemoveCmd() *cobra.Command {
|
||||
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.`,
|
||||
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 {
|
||||
@@ -209,7 +175,6 @@ func newMoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <source> <destination>",
|
||||
Aliases: []string{"mv", "rename"},
|
||||
@@ -225,16 +190,13 @@ For cross-vault moves:
|
||||
|
||||
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 // source and destination args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
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)
|
||||
@@ -244,20 +206,16 @@ The source secret is deleted after successful copy.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Overwrite if destination secret already exists")
|
||||
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 {
|
||||
const (
|
||||
doubleAfterBuffers = 2
|
||||
growthFactor = 2
|
||||
)
|
||||
|
||||
*sameSize++
|
||||
const doubleAfterBuffers = 2
|
||||
const growthFactor = 2
|
||||
if *sameSize >= doubleAfterBuffers {
|
||||
*sameSize = 0
|
||||
|
||||
@@ -267,21 +225,40 @@ func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
return currentSize
|
||||
}
|
||||
|
||||
// destroyBuffers destroys every buffer in the list
|
||||
func destroyBuffers(buffers []bufferInfo) {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
// AddSecret adds a secret to the current vault
|
||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||
|
||||
// Get current vault
|
||||
secret.Debug("Getting current vault")
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
}
|
||||
|
||||
// readSecretFromReader reads all data from reader into protected buffers,
|
||||
// enforcing the maximum secret size. On failure the accumulated buffers
|
||||
// are destroyed; on success the caller must destroy them.
|
||||
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
reader := cli.cmd.InOrStdin()
|
||||
totalSize := 0
|
||||
currentBufferSize := initialBufferSize
|
||||
currentBufferSize := initialSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
@@ -296,10 +273,8 @@ func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSecretSize {
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, errSecretTooLarge
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
@@ -308,59 +283,13 @@ func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, err
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return buffers, totalSize, nil
|
||||
}
|
||||
|
||||
// combineBuffers copies the used portions of buffers into a single
|
||||
// protected buffer of totalSize bytes
|
||||
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
return valueBuffer
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to the current vault
|
||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||
|
||||
// Get current vault
|
||||
secret.Debug("Getting current vault")
|
||||
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
|
||||
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Check for trailing newline in the last buffer
|
||||
if len(buffers) > 0 && totalSize > 0 {
|
||||
lastBuffer := &buffers[len(buffers)-1]
|
||||
@@ -370,19 +299,21 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin",
|
||||
"value_length", totalSize, "buffers", len(buffers))
|
||||
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
||||
"value_length", valueBuffer.Size(), "force", force)
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
secret.Debug("vault.AddSecret failed", "error", err)
|
||||
|
||||
return err
|
||||
@@ -399,11 +330,8 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||
func (cli *Instance) GetSecretWithVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
secret.Debug("GetSecretWithVersion called",
|
||||
"secretName", secretName, "version", version)
|
||||
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
||||
|
||||
// Store the command for output
|
||||
cli.cmd = cmd
|
||||
@@ -423,7 +351,6 @@ func (cli *Instance) GetSecretWithVersion(
|
||||
} else {
|
||||
value, err = vlt.GetSecretVersion(secretName, version)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get secret", "error", err)
|
||||
|
||||
@@ -434,7 +361,6 @@ func (cli *Instance) GetSecretWithVersion(
|
||||
|
||||
// Print the secret value to stdout
|
||||
_, _ = cli.Print(string(value))
|
||||
|
||||
secret.Debug("Printed value to stdout")
|
||||
|
||||
// Debug: Log what we're actually printing
|
||||
@@ -449,9 +375,7 @@ func (cli *Instance) GetSecretWithVersion(
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in the current vault
|
||||
func (cli *Instance) ListSecrets(
|
||||
cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string,
|
||||
) error {
|
||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -466,7 +390,6 @@ func (cli *Instance) ListSecrets(
|
||||
|
||||
// Filter secrets if filter is provided
|
||||
var filteredSecrets []string
|
||||
|
||||
if filter != "" {
|
||||
for _, secretName := range secrets {
|
||||
if strings.Contains(secretName, filter) {
|
||||
@@ -477,132 +400,100 @@ func (cli *Instance) ListSecrets(
|
||||
filteredSecrets = secrets
|
||||
}
|
||||
|
||||
switch {
|
||||
case jsonOutput:
|
||||
return printSecretsJSON(cmd, vlt, filteredSecrets, filter)
|
||||
case quietOutput:
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
secretInfo := map[string]interface{}{
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||
}
|
||||
|
||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"secrets": secretsWithMetadata,
|
||||
}
|
||||
if filter != "" {
|
||||
output["filter"] = filter
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||
} else if quietOutput {
|
||||
// Quiet output - just secret names
|
||||
for _, secretName := range filteredSecrets {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
||||
}
|
||||
|
||||
return nil
|
||||
default:
|
||||
return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets))
|
||||
}
|
||||
}
|
||||
|
||||
// printSecretsJSON prints the filtered secrets with metadata as JSON
|
||||
func printSecretsJSON(
|
||||
cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string,
|
||||
) error {
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets))
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
secretInfo := map[string]any{
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||
}
|
||||
|
||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||
}
|
||||
|
||||
output := map[string]any{
|
||||
"secrets": secretsWithMetadata,
|
||||
}
|
||||
if filter != "" {
|
||||
output["filter"] = filter
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSecretsTable prints the filtered secrets as a formatted table
|
||||
func printSecretsTable(
|
||||
cmd *cobra.Command, vlt *vault.Vault,
|
||||
filteredSecrets []string, filter string, totalCount int,
|
||||
) error {
|
||||
// Pretty table output
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if len(filteredSecrets) == 0 {
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out,
|
||||
"No secrets found in vault '%s' matching filter '%s'.\n",
|
||||
vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current vault name for display
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n",
|
||||
vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||
}
|
||||
// Pretty table output
|
||||
out := cmd.OutOrStdout()
|
||||
if len(filteredSecrets) == 0 {
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lastUpdated := "unknown"
|
||||
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||
}
|
||||
// Get current vault name for display
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount)
|
||||
}
|
||||
// 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
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
// 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 {
|
||||
lastUpdated := "unknown"
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
|
||||
}
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportSecret imports a secret from a file
|
||||
func (cli *Instance) ImportSecret(
|
||||
cmd *cobra.Command, secretName, sourceFile string, force bool,
|
||||
) error {
|
||||
func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -615,34 +506,75 @@ func (cli *Instance) ImportSecret(
|
||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := file.Close()
|
||||
if closeErr != nil {
|
||||
secret.Warn("Failed to close file", "error", closeErr)
|
||||
if err := file.Close(); err != nil {
|
||||
secret.Warn("Failed to close file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buffers, totalSize, err := readSecretFromReader(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
return errSecretFileTooLarge
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
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
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -668,36 +600,29 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
}
|
||||
|
||||
// Count versions for information
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionCount := 0
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
||||
if err == nil {
|
||||
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||
versionCount = len(entries)
|
||||
}
|
||||
|
||||
// Remove the secret directory
|
||||
err = cli.fs.RemoveAll(secretDir)
|
||||
if err != nil {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
@@ -709,20 +634,25 @@ func (cli *Instance) MoveSecret(
|
||||
|
||||
// Cross-vault move requires source to be qualified
|
||||
if !srcQualified {
|
||||
return errCrossVaultSourceUnqualified
|
||||
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 a new name
|
||||
// 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 && slices.Contains(vaults, dest) {
|
||||
// dest is a vault name, use source secret name
|
||||
destVaultName = dest
|
||||
destSecretName = srcSecretName
|
||||
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
|
||||
@@ -740,8 +670,7 @@ func (cli *Instance) MoveSecret(
|
||||
// Same vault? Use simple rename if possible (optimization)
|
||||
if srcVaultName == destVaultName {
|
||||
// Select the vault and do a simple move
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
if err != nil {
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
|
||||
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||
}
|
||||
|
||||
@@ -749,14 +678,11 @@ func (cli *Instance) MoveSecret(
|
||||
}
|
||||
|
||||
// Cross-vault move
|
||||
return cli.moveSecretCrossVault(
|
||||
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||
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 {
|
||||
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
|
||||
@@ -776,7 +702,7 @@ func (cli *Instance) moveSecretWithinVault(
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
||||
return fmt.Errorf("secret '%s' not found", source)
|
||||
}
|
||||
|
||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||
@@ -789,17 +715,15 @@ func (cli *Instance) moveSecretWithinVault(
|
||||
|
||||
if exists {
|
||||
if !force {
|
||||
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
||||
}
|
||||
|
||||
err = cli.fs.RemoveAll(destDir)
|
||||
if err != nil {
|
||||
if err := cli.fs.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = cli.fs.Rename(sourceDir, destDir)
|
||||
if err != nil {
|
||||
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||
return fmt.Errorf("failed to move secret: %w", err)
|
||||
}
|
||||
|
||||
@@ -817,8 +741,8 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
) 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)
|
||||
}
|
||||
@@ -826,7 +750,7 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// Verify source vault exists
|
||||
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
|
||||
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
@@ -835,14 +759,13 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, errSecretNotFound, srcVaultName)
|
||||
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)
|
||||
}
|
||||
@@ -850,8 +773,7 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// Verify destination vault exists
|
||||
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("destination vault '%s' %w",
|
||||
destVaultName, errVaultDoesNotExist)
|
||||
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
|
||||
}
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
@@ -865,15 +787,12 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
versionCount := len(versions)
|
||||
|
||||
// Copy all versions
|
||||
err = destVault.CopySecretAllVersions(
|
||||
srcVault, srcSecretName, destSecretName, force)
|
||||
if err != nil {
|
||||
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete source secret
|
||||
err = cli.fs.RemoveAll(srcSecretDir)
|
||||
if err != nil {
|
||||
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",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -19,144 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testVaultName is the vault name used by the size tests.
|
||||
const testVaultName = "test-vault"
|
||||
|
||||
// newSizeTestVault creates an in-memory vault unlocked with the test
|
||||
// mnemonic and returns the filesystem and vault.
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create vault
|
||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
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, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
return fs, vlt
|
||||
}
|
||||
|
||||
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
||||
// verifies the outcome.
|
||||
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add newline that will be stripped
|
||||
testDataWithNewline := make([]byte, 0, len(testData)+1)
|
||||
testDataWithNewline = append(testDataWithNewline, testData...)
|
||||
testDataWithNewline = append(testDataWithNewline, '\n')
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(testDataWithNewline))
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("test-secret-%d", size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errMsg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
// runImportSecretSizeCase imports a secret file of the given size and
|
||||
// verifies the outcome.
|
||||
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write test data to file
|
||||
testFile := fmt.Sprintf("/test/secret-%d.bin", 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 = testStateDir
|
||||
|
||||
// Test importing the secret
|
||||
secretName := fmt.Sprintf("imported-secret-%d", size)
|
||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errMsg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestAddSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -209,14 +71,76 @@ func TestAddSecretVariousSizes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||
// 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
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestImportSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -269,14 +193,73 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||
// 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
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
sizes := []int{
|
||||
@@ -300,7 +283,31 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
|
||||
for _, size := range sizes {
|
||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||
fs, vlt := newSizeTestVault(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
|
||||
@@ -309,18 +316,20 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Create command with fake stdin (no newline)
|
||||
// Create fake stdin without newline
|
||||
stdin := bytes.NewReader(testData)
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(testData))
|
||||
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 = testStateDir
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
@@ -331,38 +340,58 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// 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")
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||
//
|
||||
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
|
||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
fs, vlt := newSizeTestVault(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
|
||||
streamingStdin := &slowReader{
|
||||
slowReader := &slowReader{
|
||||
data: testData,
|
||||
chunkSize: 1000, // Read 1KB at a time
|
||||
}
|
||||
|
||||
// Create command with slow reader as stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(streamingStdin)
|
||||
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 = testStateDir
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
@@ -382,22 +411,27 @@ type slowReader struct {
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *slowReader) Read(p []byte) (int, error) {
|
||||
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, bounded by the remaining data and
|
||||
// the destination buffer
|
||||
// Read at most chunkSize bytes
|
||||
remaining := len(r.data) - r.offset
|
||||
toRead := min(r.chunkSize, remaining, len(p))
|
||||
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])
|
||||
n = copy(p, r.data[r.offset:r.offset+toRead])
|
||||
r.offset += n
|
||||
|
||||
if r.offset >= len(r.data) {
|
||||
return n, io.EOF
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
return n, nil
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -7,64 +7,57 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
||||
// value to stdout, not stderr
|
||||
// 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(secret.EnvStateDir, tempDir)
|
||||
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
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, "init")
|
||||
cmd := exec.Command(secretPath, "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"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
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
||||
cmd = exec.Command(secretPath, "add", "test/secret")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"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
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
|
||||
cmd = exec.Command(secretPath, "get", "test/secret")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -72,8 +65,7 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
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")
|
||||
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")
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||
func ExecuteCommandInProcess(
|
||||
args []string, stdin string, env map[string]string,
|
||||
) (string, error) {
|
||||
func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) {
|
||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||
|
||||
// Save current environment
|
||||
@@ -45,13 +43,11 @@ func ExecuteCommandInProcess(
|
||||
err := rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
secret.Debug("Command execution completed",
|
||||
"error", err, "outputLength", len(output), "output", output)
|
||||
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
||||
|
||||
// Add debug info for troubleshooting
|
||||
if len(output) == 0 && err == nil {
|
||||
secret.Debug("Warning: Command executed successfully but produced no output",
|
||||
"args", args)
|
||||
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
|
||||
}
|
||||
|
||||
// Restore environment
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
package cli_test
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // executes the CLI in-process against shared state
|
||||
func TestOutputCapture(t *testing.T) {
|
||||
// Test vault list command which we fixed
|
||||
output, err := cli.ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
output, err := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
||||
t.Logf("vault list output: %q", output)
|
||||
|
||||
// Test help command
|
||||
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, output, "help output should not be empty")
|
||||
t.Logf("help output length: %d", len(output))
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,37 +18,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Unlocker type names and platform identifiers shared across the CLI
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypeKeychain = "keychain"
|
||||
unlockerTypePGP = "pgp"
|
||||
unlockerTypeSecureEnclave = "secure-enclave"
|
||||
|
||||
platformDarwin = "darwin"
|
||||
|
||||
cmdUseList = "list"
|
||||
)
|
||||
|
||||
// Sentinel errors for unlocker operations
|
||||
var (
|
||||
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
|
||||
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
||||
errKeyIDOnlyForPGP = errors.New(
|
||||
"--keyid flag is only valid for PGP unlockers")
|
||||
errKeychainMacOSOnly = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
errSecureEnclaveMacOSOnly = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS")
|
||||
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
||||
// composes "GPG key <id> is already added as an unlocker".
|
||||
errGPGKeyAlreadyUnlocker = errors.New(
|
||||
"is already added as an unlocker")
|
||||
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
||||
errUnlockerExists = errors.New("unlocker already exists")
|
||||
)
|
||||
|
||||
// UnlockerInfo represents unlocker information for display
|
||||
type UnlockerInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -71,14 +37,12 @@ const (
|
||||
|
||||
// getDefaultGPGKey returns the default GPG key ID if available
|
||||
func getDefaultGPGKey() (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// First try to get the configured default key using gpgconf
|
||||
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
||||
|
||||
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
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)
|
||||
@@ -88,15 +52,15 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
|
||||
// If no default key is configured, get the first secret key
|
||||
cmd = exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons")
|
||||
|
||||
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||
}
|
||||
|
||||
// Parse output to find the first usable secret key
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
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, ":")
|
||||
@@ -107,7 +71,7 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", errNoGPGSecretKeys
|
||||
return "", fmt.Errorf("no GPG secret keys found")
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
@@ -127,7 +91,7 @@ func newUnlockerCmd() *cobra.Command {
|
||||
|
||||
func newUnlockerListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdUseList,
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List unlockers in the current vault",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -137,7 +101,6 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.UnlockersList(jsonOutput)
|
||||
@@ -149,80 +112,53 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// unlockerAddHelp returns the supported unlocker types list and their
|
||||
// descriptions for the current platform
|
||||
func unlockerAddHelp() (string, string) {
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
typeDescriptions := "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be used to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in plaintext.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
|
||||
" Uses your existing GPG key to encrypt/decrypt " +
|
||||
"the vault's master key.\n" +
|
||||
" Requires gpg to be installed and configured " +
|
||||
"with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key."
|
||||
typeDescriptions := `Available unlocker types:
|
||||
|
||||
if runtime.GOOS == platformDarwin {
|
||||
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:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be " +
|
||||
"used to encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in " +
|
||||
"plaintext.\n" +
|
||||
"\n" +
|
||||
" keychain - macOS Keychain integration (macOS only)\n" +
|
||||
" Stores the vault's master key in the " +
|
||||
"macOS Keychain, protected by your login password.\n" +
|
||||
" Automatically unlocks when your Keychain " +
|
||||
"is unlocked (e.g., after login).\n" +
|
||||
" Provides seamless integration with macOS " +
|
||||
"security features like Touch ID.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based " +
|
||||
"encryption\n" +
|
||||
" Uses your existing GPG key to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" Requires gpg to be installed and " +
|
||||
"configured with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key.\n" +
|
||||
"\n" +
|
||||
" secure-enclave - Apple Secure Enclave hardware protection " +
|
||||
"(macOS only)\n" +
|
||||
" Stores the vault's master key encrypted " +
|
||||
"by a non-exportable P-256 key\n" +
|
||||
" held in the Secure Enclave. The key " +
|
||||
"never leaves the hardware.\n" +
|
||||
" Uses ECIES encryption; decryption is " +
|
||||
"performed inside the SE."
|
||||
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.`
|
||||
}
|
||||
|
||||
return supportedTypes, typeDescriptions
|
||||
}
|
||||
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
supportedTypes, typeDescriptions := unlockerAddHelp()
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <type>",
|
||||
Short: "Add a new unlocker",
|
||||
Long: "Add a new unlocker to the current vault.\n" +
|
||||
"\n" +
|
||||
typeDescriptions + "\n" +
|
||||
"\n" +
|
||||
"Each vault can have multiple unlockers, allowing different " +
|
||||
"authentication methods\n" +
|
||||
"to access the same vault. This provides flexibility and " +
|
||||
"backup access options.",
|
||||
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 {
|
||||
@@ -230,28 +166,33 @@ func newUnlockerAddCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
unlockerType := args[0]
|
||||
|
||||
// Validate unlocker type
|
||||
validTypes := strings.Split(supportedTypes, ", ")
|
||||
if !slices.Contains(validTypes, unlockerType) {
|
||||
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
|
||||
"Run 'secret unlocker add --help' for detailed descriptions",
|
||||
errInvalidUnlockerType, unlockerType, 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 != unlockerTypePGP && cmd.Flags().Changed("keyid") {
|
||||
return errKeyIDOnlyForPGP
|
||||
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
||||
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
||||
}
|
||||
|
||||
return cli.UnlockersAdd(unlockerType, cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("keyid", "",
|
||||
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -261,20 +202,17 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
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.`,
|
||||
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)
|
||||
@@ -284,8 +222,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Force removal of last unlocker even if vault has secrets")
|
||||
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -312,92 +249,6 @@ func newUnlockerSelectCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
||||
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
||||
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
||||
func unlockerIDFromDir(
|
||||
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
||||
includeSecureEnclave bool,
|
||||
) string {
|
||||
// Create the appropriate unlocker instance
|
||||
var unlocker secret.Unlocker
|
||||
|
||||
switch metadata.Type {
|
||||
case unlockerTypePassphrase:
|
||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypeKeychain:
|
||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypePGP:
|
||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypeSecureEnclave:
|
||||
if includeSecureEnclave {
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return unlocker.GetID()
|
||||
}
|
||||
|
||||
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
||||
// stored metadata matches the given type and creation time and returns
|
||||
// the matching unlocker's ID. It returns ("", nil) when the directory is
|
||||
// readable but holds no match, and a non-nil error when the directory
|
||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||
// to be skipped rather than reported under a synthesized ID.
|
||||
func findUnlockerIDByMetadata(
|
||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||
includeSecureEnclave bool,
|
||||
) (string, error) {
|
||||
files, err := afero.ReadDir(fs, unlockersDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
||||
)
|
||||
}
|
||||
|
||||
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 file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||
if err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
if diskMetadata.Type == metadata.Type &&
|
||||
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
||||
includeSecureEnclave), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// UnlockersList lists unlockers in the current vault
|
||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
// Get current vault
|
||||
@@ -408,7 +259,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// Get the current unlocker ID
|
||||
var currentUnlockerID string
|
||||
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err == nil {
|
||||
currentUnlockerID = currentUnlocker.GetID()
|
||||
@@ -422,40 +272,74 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// Load actual unlocker objects to get the proper IDs
|
||||
var unlockers []UnlockerInfo
|
||||
|
||||
for _, metadata := range unlockerMetadataList {
|
||||
// Create unlocker instance to get the proper ID
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Warn("Could not get vault directory while listing unlockers",
|
||||
"error", err)
|
||||
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the unlocker directory by type and created time
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
unlockerID, err := findUnlockerIDByMetadata(
|
||||
cli.fs, unlockersDir, metadata, true,
|
||||
)
|
||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory, skipping unlocker",
|
||||
"unlockers_dir", unlockersDir, "error", err)
|
||||
secret.Warn("Could not read unlockers directory", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var unlocker secret.Unlocker
|
||||
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(cli.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata file", "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
|
||||
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)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get the proper ID using the unlocker's ID() method
|
||||
var properID string
|
||||
if unlockerID != "" {
|
||||
properID = unlockerID
|
||||
if unlocker != nil {
|
||||
properID = unlocker.GetID()
|
||||
} else {
|
||||
// Generate ID as fallback
|
||||
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)
|
||||
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{
|
||||
@@ -476,10 +360,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
}
|
||||
|
||||
// printUnlockersJSON prints unlockers in JSON format
|
||||
func (cli *Instance) printUnlockersJSON(
|
||||
unlockers []UnlockerInfo, currentUnlockerID string,
|
||||
) error {
|
||||
output := map[string]any{
|
||||
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
|
||||
output := map[string]interface{}{
|
||||
"unlockers": unlockers,
|
||||
"currentUnlockerID": currentUnlockerID,
|
||||
}
|
||||
@@ -513,12 +395,10 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
if len(unlocker.Flags) > 0 {
|
||||
flags = strings.Join(unlocker.Flags, ",")
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
if unlocker.IsCurrent {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
@@ -534,186 +414,164 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
|
||||
// UnlockersAdd adds a new unlocker
|
||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
if runtime.GOOS == "darwin" {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
}
|
||||
|
||||
switch unlockerType {
|
||||
case unlockerTypePassphrase:
|
||||
return cli.addPassphraseUnlocker(cmd)
|
||||
case unlockerTypeKeychain:
|
||||
return cli.addKeychainUnlocker(cmd)
|
||||
case unlockerTypeSecureEnclave:
|
||||
return cli.addSecureEnclaveUnlocker(cmd)
|
||||
case unlockerTypePGP:
|
||||
return cli.addPGPUnlocker(cmd)
|
||||
case "passphrase":
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
// 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()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case "keychain":
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||
}
|
||||
|
||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
||||
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
|
||||
|
||||
case "pgp":
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
var gpgKeyID string
|
||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||
gpgKeyID = flagKeyID
|
||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||
gpgKeyID = envKeyID
|
||||
} else {
|
||||
// Try to get the default GPG key
|
||||
defaultKeyID, err := getDefaultGPGKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||
}
|
||||
gpgKeyID = defaultKeyID
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the GPG key ID to its fingerprint
|
||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
|
||||
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
|
||||
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s (supported: %s)",
|
||||
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
||||
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||
// printing a warning if selection fails
|
||||
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||
err := vlt.SelectUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
}
|
||||
|
||||
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
|
||||
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the
|
||||
// long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
// 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()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
||||
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
||||
if runtime.GOOS != platformDarwin {
|
||||
return errKeychainMacOSOnly
|
||||
}
|
||||
|
||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||
|
||||
keyName, err := keychainUnlocker.GetKeychainItemName()
|
||||
if err == nil {
|
||||
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)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
||||
// current vault
|
||||
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
||||
if runtime.GOOS != platformDarwin {
|
||||
return errSecureEnclaveMacOSOnly
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, seUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPGPUnlocker creates a PGP unlocker in the current vault
|
||||
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
var gpgKeyID string
|
||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||
gpgKeyID = flagKeyID
|
||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||
gpgKeyID = envKeyID
|
||||
} else {
|
||||
// Try to get the default GPG key
|
||||
defaultKeyID, err := getDefaultGPGKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||
}
|
||||
|
||||
gpgKeyID = defaultKeyID
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the GPG key ID to its fingerprint
|
||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
expectedID := "pgp-" + fingerprint
|
||||
|
||||
err = cli.checkUnlockerExists(vlt, expectedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersRemove removes an unlocker with safety checks
|
||||
func (cli *Instance) UnlockersRemove(
|
||||
unlockerID string, force bool, cmd *cobra.Command,
|
||||
) error {
|
||||
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -735,24 +593,20 @@ func (cli *Instance) UnlockersRemove(
|
||||
}
|
||||
|
||||
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("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 errLastUnlocker
|
||||
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!")
|
||||
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the unlocker
|
||||
err = vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -785,29 +639,65 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
||||
// 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)
|
||||
secret.Warn("Could not get vault directory during duplicate check", "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check each unlocker's ID
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
for _, metadata := range unlockers {
|
||||
// Construct the unlocker matching this metadata to get its ID
|
||||
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||
// 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, "+
|
||||
"skipping unlocker",
|
||||
"unlockers_dir", unlockersDir, "error", err)
|
||||
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if id != "" && id == unlockerID {
|
||||
return errUnlockerExists
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
// Unlocker List Tests
|
||||
//
|
||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory
|
||||
// cannot be read while the listing is being rendered:
|
||||
//
|
||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||
// still listed, with its real ID and its current-unlocker marker,
|
||||
// when a later entry's scan fails.
|
||||
//
|
||||
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
||||
// after the vault has already enumerated it. If that rescan fails the ID
|
||||
// is unknowable, so the entry must be skipped: a synthesized ID matches
|
||||
// no `unlocker remove` or `unlocker select` argument and would also
|
||||
// suppress the current-unlocker marker.
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// listTestStateDir is the state directory of the synthetic vault used
|
||||
// by the unlocker listing tests.
|
||||
listTestStateDir = "/state"
|
||||
|
||||
// listTestVaultName is the name of that synthetic vault.
|
||||
listTestVaultName = "default"
|
||||
|
||||
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
|
||||
// unlocker's metadata. The unlocker's real ID is derived from it, and
|
||||
// differs from the timestamp-derived fallback ID.
|
||||
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||
|
||||
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
||||
// directory names under unlockers.d.
|
||||
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||
|
||||
// listTestUnlockersDirName is the directory the listing rescans to
|
||||
// resolve unlocker IDs.
|
||||
listTestUnlockersDirName = "unlockers.d"
|
||||
|
||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||
listTestMetadataFileName = "unlocker-metadata.json"
|
||||
|
||||
// listTestDirPerm and listTestFilePerm are the fixture permissions.
|
||||
listTestDirPerm = 0o700
|
||||
listTestFilePerm = 0o600
|
||||
)
|
||||
|
||||
// errUnlockersDirUnreadable is returned by the test filesystem in place of
|
||||
// a successful open of unlockers.d.
|
||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||
|
||||
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
||||
// successfully openBudget times. This reproduces the directory becoming
|
||||
// unreadable (permission change, partially restored backup, EIO) between
|
||||
// the vault's own enumeration and the per-entry rescan that resolves
|
||||
// unlocker IDs.
|
||||
type unlockersDirFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
openBudget int
|
||||
opens int
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||
if filepath.Base(name) == listTestUnlockersDirName {
|
||||
f.opens++
|
||||
if f.opens > f.openBudget {
|
||||
return nil, errUnlockersDirUnreadable
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||
// yields the real ID "pgp-<keyID>".
|
||||
func writePGPUnlocker(
|
||||
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||
createdAt time.Time, keyID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
metadata := secret.PGPUnlockerMetadata{
|
||||
UnlockerMetadata: secret.UnlockerMetadata{
|
||||
Type: unlockerTypePGP,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
GPGKeyID: keyID,
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
|
||||
dir := filepath.Join(unlockersDir, dirName)
|
||||
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
|
||||
listTestFilePerm,
|
||||
))
|
||||
}
|
||||
|
||||
// newListTestVault builds a synthetic vault on a MemMapFs containing the
|
||||
// given number of PGP unlockers, with the first one selected as current.
|
||||
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
|
||||
t.Helper()
|
||||
|
||||
base := &afero.MemMapFs{}
|
||||
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
|
||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||
|
||||
require.NoError(t, afero.WriteFile(
|
||||
base, filepath.Join(listTestStateDir, "currentvault"),
|
||||
[]byte(listTestVaultName), listTestFilePerm,
|
||||
))
|
||||
|
||||
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
|
||||
names = names[:unlockerCount]
|
||||
|
||||
for i, name := range names {
|
||||
writePGPUnlocker(t, base, unlockersDir, name,
|
||||
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
|
||||
listTestGPGKeyID+string(rune('A'+i)),
|
||||
)
|
||||
}
|
||||
|
||||
require.NoError(t, afero.WriteFile(
|
||||
base, filepath.Join(vaultDir, "current-unlocker"),
|
||||
[]byte(names[0]), listTestFilePerm,
|
||||
))
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
// listUnlockersJSON runs UnlockersList in JSON mode against the given
|
||||
// filesystem and decodes the emitted unlocker rows.
|
||||
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
|
||||
require.NoError(t, instance.UnlockersList(true))
|
||||
|
||||
var decoded struct {
|
||||
Unlockers []UnlockerInfo `json:"unlockers"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
||||
|
||||
return decoded.Unlockers
|
||||
}
|
||||
|
||||
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
|
||||
// which becomes unreadable after the vault enumerated it produces no rows,
|
||||
// rather than rows carrying fabricated fallback IDs.
|
||||
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := newListTestVault(t, 1)
|
||||
// Budget of one: the vault's own ListUnlockers scan succeeds, the
|
||||
// per-entry rescan that resolves the ID fails.
|
||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
|
||||
assert.Empty(t, unlockers,
|
||||
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
|
||||
}
|
||||
|
||||
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
|
||||
// entry survives with its real ID and current-unlocker marker when a later
|
||||
// entry's rescan fails.
|
||||
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := newListTestVault(t, 2)
|
||||
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
|
||||
// the second entry's rescan fails.
|
||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
|
||||
require.Len(t, unlockers, 1,
|
||||
"only the entry whose directory was readable may be listed")
|
||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
|
||||
"the surviving row must carry the real unlocker ID")
|
||||
assert.True(t, unlockers[0].IsCurrent,
|
||||
"the current-unlocker marker must survive the skip")
|
||||
}
|
||||
|
||||
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||
// fully readable unlockers.d every entry is listed with its real ID.
|
||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := newListTestVault(t, 2)
|
||||
|
||||
unlockers := listUnlockersJSON(t, base)
|
||||
|
||||
require.Len(t, unlockers, 2)
|
||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
|
||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
|
||||
assert.True(t, unlockers[0].IsCurrent)
|
||||
assert.False(t, unlockers[1].IsCurrent)
|
||||
}
|
||||
@@ -2,12 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,22 +18,6 @@ import (
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// Sentinel errors for vault operations
|
||||
var (
|
||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
|
||||
errVaultHasLongTermKey = errors.New(
|
||||
"already has a long-term key configured")
|
||||
errMnemonicEnvNotSet = errors.New(
|
||||
"SB_SECRET_MNEMONIC environment variable not set")
|
||||
errPassphraseEnvNotSet = errors.New(
|
||||
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||
errVaultContainsSecrets = errors.New(
|
||||
"contains secrets; use --force to remove")
|
||||
)
|
||||
|
||||
func newVaultCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "vault",
|
||||
@@ -54,7 +36,7 @@ func newVaultCmd() *cobra.Command {
|
||||
|
||||
func newVaultListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdUseList,
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -119,10 +101,9 @@ func newVaultImportCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||
`(default if not specified).`,
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -146,19 +127,16 @@ func newVaultRemoveCmd() *cobra.Command {
|
||||
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.`,
|
||||
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)
|
||||
@@ -183,13 +161,11 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
result := map[string]interface{}{
|
||||
"vaults": vaults,
|
||||
"currentVault": currentVault,
|
||||
}
|
||||
@@ -198,20 +174,16 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println(string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
cmd.Println("Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
@@ -228,57 +200,19 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setMnemonicEnv sets the mnemonic environment variable and returns a
|
||||
// function that restores the previous value
|
||||
func setMnemonicEnv(mnemonicStr string) func() {
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
|
||||
return func() {
|
||||
if originalMnemonic != "" {
|
||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePassphrase returns the unlock passphrase from the environment or
|
||||
// prompts the user for it with confirmation
|
||||
func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
|
||||
}
|
||||
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
return passphraseBuffer, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
// Get or prompt for mnemonic
|
||||
var mnemonicStr string
|
||||
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
|
||||
mnemonicStr = envMnemonic
|
||||
} else {
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadPassphrase(
|
||||
"Enter your BIP39 mnemonic phrase: ")
|
||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
|
||||
@@ -287,25 +221,30 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
defer mnemonicBuffer.Destroy()
|
||||
|
||||
mnemonicStr = mnemonicBuffer.String()
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
}
|
||||
|
||||
if mnemonicStr == "" {
|
||||
return errMnemonicEmpty
|
||||
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 errInvalidMnemonicPhrase
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase")
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||
defer restoreMnemonicEnv()
|
||||
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)
|
||||
@@ -315,7 +254,6 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
|
||||
// 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)
|
||||
@@ -331,15 +269,22 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Get or prompt for passphrase
|
||||
passphraseBuffer, err := resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
@@ -354,8 +299,7 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
|
||||
// SelectVault selects a vault as the current one
|
||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, name)
|
||||
if err != nil {
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -364,60 +308,84 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||
// and returns the vault directory, public key path, and validated mnemonic
|
||||
func (cli *Instance) vaultImportPreflight(
|
||||
vlt *vault.Vault, vaultName string,
|
||||
) (string, string, string, error) {
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
// Check if vault exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
return err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultDoesNotExist)
|
||||
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := vaultDir + "/pub.age"
|
||||
|
||||
_, err = cli.fs.Stat(pubKeyPath)
|
||||
if err == nil {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultHasLongTermKey)
|
||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
||||
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
||||
}
|
||||
|
||||
// Get mnemonic from environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return "", "", "", errMnemonicEnvNotSet
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC environment variable not set")
|
||||
}
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonic)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return "", "", "", errInvalidMnemonic
|
||||
return fmt.Errorf("invalid BIP39 mnemonic")
|
||||
}
|
||||
|
||||
return vaultDir, pubKeyPath, mnemonic, nil
|
||||
}
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate 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 fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||
func updateVaultImportMetadata(
|
||||
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||
publicKeyHash, familyHash string,
|
||||
) error {
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.Metadata{
|
||||
@@ -430,83 +398,17 @@ func updateVaultImportMetadata(
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||
if err != nil {
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault",
|
||||
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key",
|
||||
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
err = afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate 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 fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
err = updateVaultImportMetadata(
|
||||
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get passphrase from environment variable
|
||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
if passphraseStr == "" {
|
||||
return errPassphraseEnvNotSet
|
||||
return fmt.Errorf("SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
}
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
@@ -520,7 +422,6 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
@@ -535,46 +436,6 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultHasSecrets reports whether the vault directory contains any secrets
|
||||
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
exists, _ := afero.DirExists(cli.fs, secretsDir)
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||
|
||||
return err == nil && len(entries) > 0
|
||||
}
|
||||
|
||||
// switchAwayFromVault selects another vault as current before removal
|
||||
func (cli *Instance) switchAwayFromVault(
|
||||
cmd *cobra.Command, vaults []string, name string,
|
||||
) error {
|
||||
// Find another vault to switch to
|
||||
var newVault string
|
||||
|
||||
for _, v := range vaults {
|
||||
if v != name {
|
||||
newVault = v
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the new vault
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||
}
|
||||
|
||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||
|
||||
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
|
||||
@@ -584,13 +445,21 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
||||
}
|
||||
|
||||
// Check if vault exists
|
||||
if !slices.Contains(vaults, name) {
|
||||
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
||||
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 errCannotRemoveLastVault
|
||||
return fmt.Errorf("cannot remove the last vault")
|
||||
}
|
||||
|
||||
// Check if this is the current vault
|
||||
@@ -598,44 +467,57 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
||||
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
|
||||
hasSecrets := cli.vaultHasSecrets(vaultDir)
|
||||
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' %w", name, errVaultContainsSecrets)
|
||||
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
||||
}
|
||||
|
||||
// If removing current vault, switch to another vault first
|
||||
if isCurrentVault {
|
||||
err = cli.switchAwayFromVault(cmd, vaults, name)
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
err = cli.fs.RemoveAll(vaultDir)
|
||||
if err != nil {
|
||||
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")
|
||||
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/spf13/afero"
|
||||
@@ -21,12 +17,6 @@ const (
|
||||
tabWriterPadding = 2
|
||||
)
|
||||
|
||||
// Sentinel errors for version operations
|
||||
var (
|
||||
errVersionNotFound = errors.New("not found for secret")
|
||||
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
||||
)
|
||||
|
||||
// newVersionCmd returns the version management command
|
||||
func newVersionCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
@@ -42,8 +32,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Manage secret versions",
|
||||
Long: "Commands for managing secret versions including listing, " +
|
||||
"promoting, and retrieving specific versions",
|
||||
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
|
||||
}
|
||||
|
||||
// List versions command
|
||||
@@ -62,17 +51,14 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
promoteCmd := &cobra.Command{
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete secret name for first arg
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
// Version number completion for the second arg is not implemented
|
||||
// TODO: Complete version numbers for second arg
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -85,17 +71,14 @@ func VersionCommands(cli *Instance) *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 // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
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)
|
||||
}
|
||||
// Version number completion for the second arg is not implemented
|
||||
// TODO: Complete version numbers for second arg
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -138,11 +121,10 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secret not found", "secret_name", secretName)
|
||||
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
}
|
||||
|
||||
// List all versions
|
||||
@@ -163,7 +145,6 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err)
|
||||
|
||||
currentVersion = ""
|
||||
}
|
||||
|
||||
@@ -179,7 +160,44 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
|
||||
// Load and display each version's metadata
|
||||
for _, version := range versions {
|
||||
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := "-"
|
||||
if sv.Metadata.CreatedAt != nil {
|
||||
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notBefore := "-"
|
||||
if sv.Metadata.NotBefore != nil {
|
||||
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notAfter := "-"
|
||||
if sv.Metadata.NotAfter != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
_ = w.Flush()
|
||||
@@ -187,58 +205,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// printVersionRow loads one version's metadata and writes its table row
|
||||
func printVersionRow(
|
||||
w io.Writer, vlt *vault.Vault,
|
||||
secretName, version, currentVersion string,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) {
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
err := sv.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Warn("Failed to load version metadata",
|
||||
"version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
||||
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
||||
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
version, createdAt, status, notBefore, notAfter)
|
||||
}
|
||||
|
||||
// formatVersionTime formats an optional version timestamp, "-" when unset
|
||||
func formatVersionTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// PromoteVersion promotes a specific version to current
|
||||
func (cli *Instance) PromoteVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -256,20 +224,16 @@ func (cli *Instance) PromoteVersion(
|
||||
|
||||
// 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' %w '%s'",
|
||||
version, errVersionNotFound, secretName)
|
||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink using the proper function
|
||||
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||
return fmt.Errorf("failed to update current version: %w", err)
|
||||
}
|
||||
|
||||
@@ -279,9 +243,7 @@ func (cli *Instance) PromoteVersion(
|
||||
}
|
||||
|
||||
// RemoveVersion removes a specific version of a secret
|
||||
func (cli *Instance) RemoveVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
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 {
|
||||
@@ -302,22 +264,18 @@ func (cli *Instance) RemoveVersion(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
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' %w '%s'",
|
||||
version, errVersionNotFound, secretName)
|
||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||
}
|
||||
|
||||
// Get current version
|
||||
@@ -328,13 +286,11 @@ func (cli *Instance) RemoveVersion(
|
||||
|
||||
// Don't allow removing the current version
|
||||
if version == currentVersion {
|
||||
return fmt.Errorf("cannot remove the current version '%s'; %w",
|
||||
version, errCannotRemoveCurrentVersion)
|
||||
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
||||
}
|
||||
|
||||
// Remove the version directory
|
||||
err = cli.fs.RemoveAll(versionDir)
|
||||
if err != nil {
|
||||
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
||||
return fmt.Errorf("failed to remove version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
// - setupTestVault(): CLI test helper for vault initialization
|
||||
// - Uses consistent test mnemonic for reproducible testing
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -33,41 +32,29 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// testStateDir is the in-memory state directory used by CLI tests.
|
||||
testStateDir = "/test/state"
|
||||
)
|
||||
|
||||
// Helper function to add a version of the "test/secret" secret to the
|
||||
// vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||
// 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("test/secret", buffer, force)
|
||||
err := vlt.AddSecret(name, buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to set up a vault with long-term key in testStateDir
|
||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
// Helper function to set up a vault with long-term key
|
||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Set mnemonic for testing
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
@@ -77,32 +64,30 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
err = vault.SelectVault(fs, testStateDir, "default")
|
||||
err = vault.SelectVault(fs, stateDir, "default")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -127,28 +112,24 @@ func TestListVersionsCommand(t *testing.T) {
|
||||
// Should have two version entries
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
versionLines := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||
versionLines++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, versionLines)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -159,24 +140,23 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestPromoteVersionCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -195,7 +175,6 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -216,24 +195,22 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("value"), false)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -244,24 +221,23 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -272,7 +248,6 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
@@ -283,21 +258,18 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
|
||||
// Test getting specific version
|
||||
buf.Reset()
|
||||
|
||||
firstVersion := versions[1] // Older version
|
||||
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version-1", buf.String())
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestVersionCommandStructure(t *testing.T) {
|
||||
// Test that version commands are properly structured
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := VersionCommands(cli)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
@@ -313,14 +285,13 @@ func TestVersionCommandStructure(t *testing.T) {
|
||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Create a secret directory without versions (edge case)
|
||||
vaultDir := stateDir + "/vaults.d/default"
|
||||
@@ -330,7 +301,6 @@ func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||
package macse
|
||||
|
||||
import "errors"
|
||||
import "fmt"
|
||||
|
||||
var errNotSupported = errors.New("secure enclave is only supported on macOS")
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build darwin
|
||||
|
||||
#ifndef SECURE_ENCLAVE_H
|
||||
#define SECURE_ENCLAVE_H
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Security/Security.h>
|
||||
#include "secure_enclave.h"
|
||||
|
||||
@@ -12,8 +12,7 @@ const (
|
||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||
//nolint:gosec // G101: env var name, not a credential
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,61 +12,39 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errStdinNotTerminal = errors.New(
|
||||
"cannot read passphrase from non-terminal stdin " +
|
||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable or run interactively")
|
||||
errStderrNotTerminal = errors.New(
|
||||
"cannot prompt for passphrase: stderr is not a terminal " +
|
||||
"(running in non-interactive mode). Please set the " +
|
||||
"SB_UNLOCK_PASSPHRASE environment variable")
|
||||
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
||||
)
|
||||
|
||||
// EncryptToRecipient encrypts data to a recipient using age
|
||||
// The data parameter should be a LockedBuffer for secure memory handling
|
||||
func EncryptToRecipient(
|
||||
data *memguard.LockedBuffer, recipient age.Recipient,
|
||||
) ([]byte, error) {
|
||||
func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
}
|
||||
|
||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
Debug("Creating age encryptor")
|
||||
|
||||
w, err := age.Encrypt(&buf, recipient)
|
||||
if err != nil {
|
||||
Debug("Failed to create encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||
}
|
||||
|
||||
Debug("Created age encryptor successfully")
|
||||
Debug("Writing data to encryptor")
|
||||
|
||||
_, err = w.Write(data.Bytes())
|
||||
if err != nil {
|
||||
Debug("Writing data to encryptor")
|
||||
if _, err := w.Write(data.Bytes()); err != nil {
|
||||
Debug("Failed to write data to encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
Debug("Closing encryptor")
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
Debug("Closing encryptor")
|
||||
if err := w.Close(); err != nil {
|
||||
Debug("Failed to close encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||
}
|
||||
|
||||
Debug("Closed encryptor successfully")
|
||||
|
||||
result := buf.Bytes()
|
||||
@@ -77,9 +54,7 @@ func EncryptToRecipient(
|
||||
}
|
||||
|
||||
// DecryptWithIdentity decrypts data with an identity using age
|
||||
func DecryptWithIdentity(
|
||||
data []byte, identity age.Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||
@@ -93,8 +68,7 @@ func DecryptWithIdentity(
|
||||
// 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
|
||||
// Zero out the original slice to prevent plaintext from lingering in unprotected memory
|
||||
for i := range result {
|
||||
result[i] = 0
|
||||
}
|
||||
@@ -102,22 +76,17 @@ func DecryptWithIdentity(
|
||||
return resultBuffer, nil
|
||||
}
|
||||
|
||||
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
||||
// scrypt-based encryption. Both data and passphrase parameters should
|
||||
// be LockedBuffers for secure memory handling
|
||||
func EncryptWithPassphrase(
|
||||
data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
|
||||
) ([]byte, error) {
|
||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
}
|
||||
|
||||
if passphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
}
|
||||
|
||||
// Create recipient directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||
@@ -126,18 +95,14 @@ func EncryptWithPassphrase(
|
||||
return EncryptToRecipient(data, recipient)
|
||||
}
|
||||
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's
|
||||
// scrypt-based decryption. The passphrase parameter should be a
|
||||
// LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(
|
||||
encryptedData []byte, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||
if passphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
}
|
||||
|
||||
// Create identity directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||
@@ -152,30 +117,29 @@ func DecryptWithPassphrase(
|
||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read passphrases from piped input
|
||||
// for security reasons
|
||||
return nil, errStdinNotTerminal
|
||||
// Not a terminal - never read passphrases from piped input for security reasons
|
||||
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(syscall.Stderr) {
|
||||
return nil, errStderrNotTerminal
|
||||
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
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
|
||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
// Print newline to stderr since ReadPassword doesn't echo
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||
|
||||
if len(passphrase) == 0 {
|
||||
return nil, errEmptyPassphrase
|
||||
return nil, fmt.Errorf("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
// Create a secure buffer and copy the passphrase
|
||||
|
||||
@@ -13,33 +13,28 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
debugEnabled bool //nolint:gochecknoglobals // package debug state
|
||||
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
|
||||
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
|
||||
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
|
||||
)
|
||||
|
||||
//nolint:gochecknoinits // debug logging must be ready before any package use
|
||||
func init() {
|
||||
InitDebugLogging()
|
||||
}
|
||||
|
||||
// InitDebugLogging initializes the debug logging system based on the
|
||||
// current GODEBUG environment variable
|
||||
// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
|
||||
func InitDebugLogging() {
|
||||
godebug := os.Getenv("GODEBUG")
|
||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||
|
||||
if !debugEnabled {
|
||||
// Create a no-op logger that discards all output
|
||||
debugLogger = slog.New(slog.DiscardHandler)
|
||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disable stderr buffering for immediate debug output when
|
||||
// debugging is enabled
|
||||
//nolint:dogsled // syscall.Syscall returns three values, none needed
|
||||
_, _, _ = syscall.Syscall(
|
||||
syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
// Disable stderr buffering for immediate debug output when debugging is enabled
|
||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
|
||||
// Check if STDERR is a TTY
|
||||
isTTY := term.IsTerminal(syscall.Stderr)
|
||||
@@ -63,19 +58,14 @@ func IsDebugEnabled() bool {
|
||||
return debugEnabled
|
||||
}
|
||||
|
||||
// Warn logs a warning message to stderr unconditionally (visible
|
||||
// without --verbose or debug flags)
|
||||
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
|
||||
func Warn(msg string, args ...any) {
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("WARNING: " + msg)
|
||||
|
||||
output := fmt.Sprintf("WARNING: %s", msg)
|
||||
for i := 0; i+1 < len(args); i += 2 {
|
||||
fmt.Fprintf(&output, " %s=%v", args[i], args[i+1])
|
||||
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
output += "\n"
|
||||
fmt.Fprint(os.Stderr, output)
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional attributes
|
||||
@@ -83,16 +73,14 @@ func Debug(msg string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(msg, args...)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message with optional attributes
|
||||
func Debugf(format string, args ...any) {
|
||||
// DebugF logs a formatted debug message with optional attributes
|
||||
func DebugF(format string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
@@ -101,7 +89,6 @@ func DebugWith(msg string, attrs ...slog.Attr) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
||||
}
|
||||
|
||||
@@ -131,18 +118,15 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
if record.NumAttrs() > 0 {
|
||||
output += " \033[33m{"
|
||||
first := true
|
||||
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
if !first {
|
||||
output += ", "
|
||||
}
|
||||
|
||||
first = false
|
||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
output += "}\033[0m"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//nolint:testpackage // white-box test of unexported debug internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -91,11 +90,9 @@ func TestDebugLogging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // exercises process-global debug logger state
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// Enable debug for testing
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
|
||||
defer InitDebugLogging() // Re-initialize after test
|
||||
|
||||
InitDebugLogging()
|
||||
@@ -110,8 +107,8 @@ func TestDebugFunctions(t *testing.T) {
|
||||
Debug("test with args", "key", "value", "number", 42)
|
||||
})
|
||||
|
||||
t.Run("Debugf", func(_ *testing.T) {
|
||||
Debugf("formatted message: %s %d", "test", 123)
|
||||
t.Run("DebugF", func(_ *testing.T) {
|
||||
DebugF("formatted message: %s %d", "test", 123)
|
||||
})
|
||||
|
||||
t.Run("DebugWith", func(_ *testing.T) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DetermineStateDir determines the state directory based on environment
|
||||
// variables and OS.
|
||||
// generateRandomString generates a random string of the specified length using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := range length {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// DetermineStateDir determines the state directory based on environment variables and OS.
|
||||
// It returns an error if no usable directory can be determined.
|
||||
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||
// Check for environment variable first
|
||||
@@ -29,14 +50,11 @@ func DetermineStateDir(customConfigDir string) (string, error) {
|
||||
// Fallback to a reasonable default if we can't determine user config dir
|
||||
homeDir, homeErr := os.UserHomeDir()
|
||||
if homeErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"unable to determine state directory: config dir: %w, home dir: %w",
|
||||
err, homeErr)
|
||||
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
||||
}
|
||||
|
||||
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
||||
Warn("Could not determine user config directory, falling back to default",
|
||||
"fallback", fallbackDir, "error", err)
|
||||
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
|
||||
|
||||
return fallbackDir, nil
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// generateRandomString generates a random string of the specified length using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := range length {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package secret_test
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
)
|
||||
|
||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
@@ -11,11 +9,11 @@ func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
// 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(secret.EnvStateDir, "")
|
||||
t.Setenv(EnvStateDir, "")
|
||||
t.Setenv("HOME", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
result, err := secret.DetermineStateDir("")
|
||||
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).
|
||||
@@ -23,36 +21,29 @@ func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
// Good — the error case is handled.
|
||||
return
|
||||
}
|
||||
|
||||
if result == "/.config/"+secret.AppID || result == "" {
|
||||
t.Errorf(
|
||||
"DetermineStateDir returned dangerous/empty path %q without error",
|
||||
result)
|
||||
if result == "/.config/"+AppID || result == "" {
|
||||
t.Errorf("DetermineStateDir returned dangerous/empty path %q without error", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||
t.Setenv(secret.EnvStateDir, "/custom/state")
|
||||
|
||||
result, err := secret.DetermineStateDir("")
|
||||
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(secret.EnvStateDir, "")
|
||||
|
||||
result, err := secret.DetermineStateDir("/my/config")
|
||||
t.Setenv(EnvStateDir, "")
|
||||
result, err := DetermineStateDir("/my/config")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := "/my/config/" + secret.AppID
|
||||
expected := "/my/config/" + AppID
|
||||
if result != expected {
|
||||
t.Errorf("expected %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
@@ -24,21 +24,7 @@ type KeychainUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
var errKeychainNotSupported = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -62,7 +48,7 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
||||
|
||||
// GetID returns the unlocker ID
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
|
||||
return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04"))
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||
@@ -75,14 +61,22 @@ 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) {
|
||||
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
@@ -13,134 +13,29 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// testMnemonic is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
|
||||
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||
func writeTestPublicKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms)
|
||||
// Create a temporary directory for our tests
|
||||
tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // Clean up after test
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
// Test data
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
testPassphrase := "test-passphrase-123"
|
||||
|
||||
// writeTestPrivateKey encrypts the private key with the passphrase,
|
||||
// writes it, and verifies it exists.
|
||||
func writeTestPrivateKey(
|
||||
t *testing.T,
|
||||
fs afero.Fs,
|
||||
unlockerDir string,
|
||||
agePrivateKey string,
|
||||
testPassphrase string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(
|
||||
privKeyBuffer, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestLongTermKey encrypts the derived long-term key to the
|
||||
// unlocker's recipient, writes it, and verifies it exists.
|
||||
func writeTestLongTermKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Derive a long-term identity from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker's recipient
|
||||
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestPassphraseUnlocker creates a temp unlocker directory and a
|
||||
// passphrase unlocker with a fresh age identity for testing.
|
||||
func newTestPassphraseUnlocker(
|
||||
t *testing.T, fs afero.Fs,
|
||||
) (*secret.PassphraseUnlocker, *age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create the directory structure in a temp dir
|
||||
unlockerDir := filepath.Join(t.TempDir(), "unlocker")
|
||||
|
||||
err := os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
// Create the directory structure
|
||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -159,40 +54,86 @@ func newTestPassphraseUnlocker(
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate age identity: %v", err)
|
||||
}
|
||||
|
||||
return unlocker, ageIdentity, unlockerDir
|
||||
}
|
||||
|
||||
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test data
|
||||
testPassphrase := "test-passphrase-123"
|
||||
|
||||
unlocker, ageIdentity, unlockerDir := newTestPassphraseUnlocker(t, fs)
|
||||
agePrivateKey := ageIdentity.String()
|
||||
agePublicKey := ageIdentity.Recipient().String()
|
||||
|
||||
// Test writing public key
|
||||
t.Run("WritePublicKey", func(t *testing.T) {
|
||||
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
})
|
||||
|
||||
// Test encrypting private key with passphrase
|
||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||
defer privKeyBuffer.Destroy()
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
||||
}
|
||||
})
|
||||
|
||||
// Test writing long-term key
|
||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||
// Derive a long-term identity from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker's recipient
|
||||
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
||||
}
|
||||
})
|
||||
|
||||
// Set test environment variable (cleaned up automatically)
|
||||
@@ -207,21 +148,18 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
|
||||
// Verify the identity matches what we expect
|
||||
expectedPubKey := ageIdentity.Recipient().String()
|
||||
|
||||
actualPubKey := identity.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Unset the environment variable to test interactive prompt
|
||||
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Test getting identity from prompt (this would require mocking the
|
||||
// prompt). For real integration tests, we'd need a way to mock the
|
||||
// passphrase input. Here we just verify the error is what we expect
|
||||
// when no passphrase is available.
|
||||
// Test getting identity from prompt (this would require mocking the prompt)
|
||||
// For real integration tests, we'd need to provide a way to mock the passphrase input
|
||||
// Here we'll just verify the error is what we expect when no passphrase is available
|
||||
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||
// This should fail since we're not in an interactive terminal
|
||||
_, err := unlocker.GetIdentity()
|
||||
@@ -242,7 +180,6 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
t.Errorf("Unlocker directory should not exist after removal")
|
||||
}
|
||||
|
||||
@@ -19,15 +19,37 @@ type PassphraseUnlocker struct {
|
||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||
}
|
||||
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
// 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
|
||||
@@ -49,8 +71,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||
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)
|
||||
}
|
||||
@@ -65,8 +86,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
// Decrypt the unlocker private key with passphrase
|
||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||
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)
|
||||
}
|
||||
@@ -115,7 +135,7 @@ func (p *PassphraseUnlocker) GetID() string {
|
||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||
createdAt := p.Metadata.CreatedAt
|
||||
|
||||
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
|
||||
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||
@@ -127,45 +147,20 @@ func (p *PassphraseUnlocker) Remove() error {
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
if err != nil {
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
@@ -142,7 +140,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
|
||||
defer os.RemoveAll(tempDir) // Clean up after test
|
||||
|
||||
// Create a temporary GNUPGHOME
|
||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -18,28 +16,17 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
||||
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
||||
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
|
||||
errNilDataBuffer = errors.New("data buffer is nil")
|
||||
)
|
||||
|
||||
// Variables to allow overriding in tests
|
||||
var (
|
||||
// GPGEncryptFunc is the function used for GPG encryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGEncryptFunc func(
|
||||
data *memguard.LockedBuffer, keyID string,
|
||||
) ([]byte, error) = gpgEncryptDefault
|
||||
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
|
||||
|
||||
// GPGDecryptFunc is the function used for GPG decryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGDecryptFunc func(
|
||||
encryptedData []byte,
|
||||
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
|
||||
// gpgKeyIDRegex validates GPG key IDs
|
||||
// Allows either:
|
||||
@@ -58,7 +45,6 @@ var (
|
||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||
type PGPUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// GPG key ID used for encryption
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
@@ -70,17 +56,6 @@ type PGPUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
||||
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting PGP unlocker identity",
|
||||
@@ -94,8 +69,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||
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)
|
||||
}
|
||||
@@ -107,11 +81,9 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 2: Decrypt the age private key using GPG
|
||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||
|
||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
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)
|
||||
}
|
||||
@@ -124,7 +96,6 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 3: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||
@@ -165,43 +136,47 @@ func (p *PGPUnlocker) GetID() string {
|
||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||
}
|
||||
|
||||
return "pgp-" + gpgKeyID
|
||||
return fmt.Sprintf("pgp-%s", gpgKeyID)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||
func (p *PGPUnlocker) Remove() error {
|
||||
// For PGP unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
if err != nil {
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGPGKeyID returns the GPG key ID from metadata
|
||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||
// Load the metadata
|
||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
var pgpMetadata PGPUnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataData, &pgpMetadata)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
|
||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
return pgpMetadata.GPGKeyID, nil
|
||||
}
|
||||
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||
// based on hostname and date
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
|
||||
func generatePGPUnlockerName() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
@@ -214,55 +189,34 @@ func generatePGPUnlockerName() (string, error) {
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||
}
|
||||
|
||||
// preparePGPUnlockerDir checks GPG availability and creates the
|
||||
// unlocker directory in the current vault, returning the vault and the
|
||||
// directory path.
|
||||
//
|
||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||
func preparePGPUnlockerDir(
|
||||
fs afero.Fs, stateDir string,
|
||||
) (VaultInterface, string, error) {
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
|
||||
// Check if GPG is available
|
||||
err := checkGPGAvailable()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
if err := checkGPGAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate the unlocker name based on hostname and date
|
||||
unlockerName, err := generatePGPUnlockerName()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory using the generated name
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
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", unlockerName)
|
||||
|
||||
err = fs.MkdirAll(unlockerDir, DirPerms)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return vault, unlockerDir, nil
|
||||
}
|
||||
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||
func CreatePGPUnlocker(
|
||||
fs afero.Fs, stateDir string, gpgKeyID string,
|
||||
) (*PGPUnlocker, error) {
|
||||
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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 PGP unlocker
|
||||
@@ -274,9 +228,7 @@ func CreatePGPUnlocker(
|
||||
// Step 2: Store age recipient as plaintext
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
|
||||
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
@@ -288,18 +240,14 @@ func CreatePGPUnlocker(
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 7: 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 {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -314,35 +262,17 @@ func CreatePGPUnlocker(
|
||||
}
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
|
||||
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
|
||||
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PGPUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: pgpMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
|
||||
// the unlocker metadata file, returning the metadata written.
|
||||
func writePGPUnlockerMetadata(
|
||||
fs afero.Fs, unlockerDir string, gpgKeyID string,
|
||||
) (*PGPUnlockerMetadata, error) {
|
||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Create and write enhanced metadata with full fingerprint
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
@@ -357,24 +287,27 @@ func writePGPUnlockerMetadata(
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs,
|
||||
if err := afero.WriteFile(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms)
|
||||
if err != nil {
|
||||
metadataBytes, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
return &pgpMetadata, nil
|
||||
return &PGPUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: pgpMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||
func validateGPGKeyID(keyID string) error {
|
||||
if keyID == "" {
|
||||
return errGPGKeyIDEmpty
|
||||
return fmt.Errorf("GPG key ID cannot be empty")
|
||||
}
|
||||
|
||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
||||
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -382,24 +315,22 @@ func validateGPGKeyID(keyID string) error {
|
||||
|
||||
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
// Use GPG to get the full fingerprint for the key
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||
context.Background(),
|
||||
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Parse the output to extract the fingerprint
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
@@ -408,18 +339,14 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
||||
return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
|
||||
}
|
||||
|
||||
// checkGPGAvailable verifies that GPG is available
|
||||
func checkGPGAvailable() error {
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
||||
err)
|
||||
cmd := exec.Command("gpg", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -428,16 +355,13 @@ func checkGPGAvailable() error {
|
||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
}
|
||||
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||
context.Background(),
|
||||
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(data.String())
|
||||
@@ -452,7 +376,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
||||
|
||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
||||
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -2,7 +2,6 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -16,18 +15,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
// errSecretNotFound carries only the message tail; callers compose
|
||||
// "secret <name> not found" around it so the emitted text is
|
||||
// unchanged.
|
||||
errSecretNotFound = errors.New("not found")
|
||||
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
||||
errGetEncryptedDataDeprecated = errors.New(
|
||||
"GetEncryptedData is deprecated - use version-specific methods")
|
||||
errGetCurrentVaultNotRegistered = errors.New(
|
||||
"GetCurrentVault function not registered")
|
||||
)
|
||||
|
||||
// VaultInterface defines the interface that vault implementations must satisfy
|
||||
type VaultInterface interface {
|
||||
GetDirectory() (string, error)
|
||||
@@ -35,8 +22,7 @@ type VaultInterface interface {
|
||||
GetName() string
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
CreatePassphraseUnlocker(
|
||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
}
|
||||
|
||||
// Secret represents a secret in a vault
|
||||
@@ -76,8 +62,7 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
}
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the current version's value using the
|
||||
// provided unlocker
|
||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
@@ -87,17 +72,14 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
// Check if secret exists
|
||||
exists, err := s.Exists()
|
||||
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)
|
||||
}
|
||||
|
||||
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 %w", s.Name, errSecretNotFound)
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
}
|
||||
|
||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||
@@ -113,9 +95,52 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
// Create version object
|
||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
return s.getValueViaMnemonic(version, envMnemonic)
|
||||
Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
@@ -124,12 +149,51 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
if unlocker == nil {
|
||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||
|
||||
return nil, errUnlockerRequired
|
||||
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
||||
}
|
||||
|
||||
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
DebugWith("Successfully obtained vault's long-term key",
|
||||
@@ -143,8 +207,7 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
s.Metadata = Metadata{
|
||||
@@ -164,10 +227,9 @@ func (s *Secret) GetMetadata() Metadata {
|
||||
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
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, errGetEncryptedDataDeprecated
|
||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
@@ -180,8 +242,7 @@ func (s *Secret) Exists() (bool, error) {
|
||||
// Check if the secret directory exists and has a current symlink
|
||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||
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
|
||||
}
|
||||
@@ -208,134 +269,14 @@ func (s *Secret) Exists() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getValueViaMnemonic derives the vault's long-term key from the
|
||||
// mnemonic in the environment and decrypts the version value with it.
|
||||
func (s *Secret) getValueViaMnemonic(
|
||||
version *Version, envMnemonic string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
Debug("Using mnemonic from environment for direct long-term key derivation",
|
||||
"secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
||||
// the vault's long-term private key.
|
||||
func (s *Secret) getLongTermIdentityFromUnlocker(
|
||||
unlocker Unlocker,
|
||||
) (*age.X25519Identity, error) {
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(
|
||||
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
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 ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
// This function is a wrapper around the actual implementation in the vault package
|
||||
// and exists to break the import cycle.
|
||||
//
|
||||
//nolint:ireturn // must return the interface to break the import cycle
|
||||
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
// This is a forward declaration. The actual implementation is provided
|
||||
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
||||
if getCurrentVaultFunc == nil {
|
||||
return nil, errGetCurrentVaultNotRegistered
|
||||
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
||||
}
|
||||
|
||||
return getCurrentVaultFunc(fs, stateDir)
|
||||
@@ -347,10 +288,8 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
//nolint:gochecknoglobals // Required to break import cycle
|
||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||
|
||||
// RegisterGetCurrentVaultFunc allows the vault package to register its
|
||||
// implementation of GetCurrentVault to break the import cycle
|
||||
func RegisterGetCurrentVaultFunc(
|
||||
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
|
||||
) {
|
||||
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
||||
// of GetCurrentVault to break the import cycle
|
||||
func RegisterGetCurrentVaultFunc(fn func(fs afero.Fs, stateDir string) (VaultInterface, error)) {
|
||||
getCurrentVaultFunc = fn
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,17 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
var (
|
||||
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
|
||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
)
|
||||
|
||||
// MockVault is a test implementation of the VaultInterface
|
||||
type MockVault struct {
|
||||
name string
|
||||
@@ -42,18 +30,14 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
// Create secret directory with proper storage name conversion
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
|
||||
err := m.fs.MkdirAll(secretDir, 0o700)
|
||||
if err != nil {
|
||||
if err := m.fs.MkdirAll(secretDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create version directory with proper path
|
||||
versionName := "20240101.001" // Use a fixed version name for testing
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
|
||||
err = m.fs.MkdirAll(versionDir, 0o700)
|
||||
if err != nil {
|
||||
if err := m.fs.MkdirAll(versionDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -63,7 +47,7 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
// Derive long-term key using the vault's derivation index
|
||||
mnemonic := os.Getenv(EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return errMnemonicNotSet
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
||||
@@ -72,54 +56,13 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
}
|
||||
|
||||
// Write long-term public key if it doesn't exist
|
||||
_, err = m.fs.Stat(ltPubKeyPath)
|
||||
if os.IsNotExist(err) {
|
||||
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
|
||||
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.writeVersionFiles(versionDir, value, ltIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current file pointing to the version (just the version name)
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
|
||||
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
// writeVersionFiles generates a version keypair and writes the version
|
||||
// key and value files for the mock vault.
|
||||
func (m *MockVault) writeVersionFiles(
|
||||
versionDir string,
|
||||
value *memguard.LockedBuffer,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
// Generate version-specific keypair
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
@@ -128,10 +71,7 @@ func (m *MockVault) writeVersionFiles(
|
||||
|
||||
// Write version public key
|
||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(
|
||||
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -143,32 +83,60 @@ func (m *MockVault) writeVersionFiles(
|
||||
|
||||
// Write encrypted value
|
||||
valuePath := filepath.Join(versionDir, "value.age")
|
||||
|
||||
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt version private key to long-term public key
|
||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(
|
||||
versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted version private key
|
||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
||||
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
||||
// Create current file pointing to the version (just the version name)
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupMockVaultDirs creates the vault directory structure, long-term
|
||||
// public key, and current vault pointer for tests.
|
||||
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||
t.Helper()
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
|
||||
// Create vault directory structure
|
||||
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
||||
@@ -177,14 +145,13 @@ func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||
}
|
||||
|
||||
// Generate a long-term keypair for the vault using the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonicValue, 0)
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(
|
||||
fs,
|
||||
ltPubKeyPath,
|
||||
@@ -197,56 +164,10 @@ func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
||||
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set current vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySecretFiles checks that AddSecret created the expected version
|
||||
// files for the secret.
|
||||
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
// Check current file exists and points at a version
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
|
||||
valueExists, err := afero.Exists(fs, filepath.Join(versionDir, "value.age"))
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv (process-global environment)
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
|
||||
setupMockVaultDirs(t, fs, baseDir, vaultDir)
|
||||
|
||||
// Create vault instance using the mock vault
|
||||
vault := &MockVault{
|
||||
@@ -272,7 +193,30 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify that all expected files were created
|
||||
verifySecretFiles(t, fs, vaultDir, secretName)
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
// Check current symlink exists
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
valueExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(versionDir, "value.age"),
|
||||
)
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
@@ -301,11 +245,9 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking if secret exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Secret should exist but Exists() returned false")
|
||||
}
|
||||
|
||||
t.Logf("Secret.Exists() works correctly")
|
||||
})
|
||||
}
|
||||
@@ -332,8 +274,6 @@ func isValidSecretName(name string) bool {
|
||||
}
|
||||
|
||||
func TestSecretNameValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
@@ -353,8 +293,6 @@ func TestSecretNameValidation(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := isValidSecretName(test.name)
|
||||
if result != test.valid {
|
||||
t.Errorf(
|
||||
@@ -373,13 +311,13 @@ func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
// instead of the vault's actual derivation index when using environment mnemonic
|
||||
|
||||
// Set up test mnemonic
|
||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create temporary directory for vaults
|
||||
fs := afero.NewOsFs()
|
||||
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = fs.RemoveAll(tempDir)
|
||||
}()
|
||||
|
||||
@@ -60,10 +60,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
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,
|
||||
)
|
||||
return nil, fmt.Errorf("failed to read SE-encrypted long-term key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read SE-encrypted long-term key",
|
||||
@@ -73,10 +70,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
// 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,
|
||||
)
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key with SE: %w", err)
|
||||
}
|
||||
|
||||
// Parse the decrypted long-term private key
|
||||
@@ -88,10 +82,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term key via SE",
|
||||
@@ -174,11 +165,7 @@ func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err e
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
func NewSecureEnclaveUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
@@ -195,22 +182,13 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
||||
|
||||
enrollmentDate := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s.%s-%s-%s",
|
||||
seKeyLabelPrefix,
|
||||
vaultName,
|
||||
hostname,
|
||||
enrollmentDate,
|
||||
), nil
|
||||
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) {
|
||||
func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUnlocker, error) {
|
||||
if err := checkMacOSAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -238,20 +216,14 @@ func CreateSecureEnclaveUnlocker(
|
||||
// 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
return nil, fmt.Errorf("failed to encrypt long-term key with SE: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Create unlocker directory and write files
|
||||
@@ -263,19 +235,13 @@ func CreateSecureEnclaveUnlocker(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
return nil, fmt.Errorf("failed to write SE-encrypted long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
@@ -308,10 +274,7 @@ func CreateSecureEnclaveUnlocker(
|
||||
|
||||
// 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) {
|
||||
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
|
||||
@@ -332,16 +295,9 @@ func getLongTermKeyForSE(
|
||||
}
|
||||
|
||||
// Use mnemonic with the vault's actual derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(
|
||||
envMnemonic,
|
||||
metadata.DerivationIndex,
|
||||
)
|
||||
|
||||
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 nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||
@@ -354,29 +310,17 @@ func getLongTermKeyForSE(
|
||||
|
||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to get current unlocker identity: %w",
|
||||
err,
|
||||
)
|
||||
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",
|
||||
)
|
||||
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,
|
||||
)
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||
encryptedLtKey,
|
||||
currentIdentity,
|
||||
)
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtKey, currentIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// seUnlockerType is the type string for Secure Enclave unlockers.
|
||||
const seUnlockerType = "secure-enclave"
|
||||
|
||||
var errSENotSupported = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS",
|
||||
)
|
||||
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"`
|
||||
}
|
||||
@@ -31,21 +26,6 @@ type SecureEnclaveUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errSENotSupported
|
||||
@@ -53,7 +33,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// GetType returns the unlocker type.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return seUnlockerType
|
||||
return "secure-enclave"
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata.
|
||||
@@ -68,7 +48,7 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
|
||||
// GetID returns the unlocker ID.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
|
||||
return fmt.Sprintf("%s-secure-enclave", s.Metadata.CreatedAt.Format("2006-01-02.15.04"))
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms.
|
||||
@@ -76,10 +56,17 @@ 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) {
|
||||
func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) {
|
||||
return nil, errSENotSupported
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -13,21 +13,19 @@ import (
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
Type: "secure-enclave",
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
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())
|
||||
assert.Equal(t, "secure-enclave", unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
@@ -41,11 +39,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
Type: "secure-enclave",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
@@ -53,43 +49,37 @@ func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
Type: "secure-enclave",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
err := unlocker.Remove()
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||
assert.Nil(t, unlocker)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
Type: "secure-enclave",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "valid simple name",
|
||||
itemName: "my-secret-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with dots",
|
||||
itemName: "com.example.app.key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
itemName: "my_secret_key_123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid alphanumeric",
|
||||
itemName: "Secret123Key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with hyphen at start",
|
||||
itemName: "-my-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with dot at start",
|
||||
itemName: ".hidden-key",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "empty item name",
|
||||
itemName: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with spaces",
|
||||
itemName: "my secret key",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with semicolon",
|
||||
itemName: "key;rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with pipe",
|
||||
itemName: "key|cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backticks",
|
||||
itemName: "key`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with dollar sign",
|
||||
itemName: "key$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with quotes",
|
||||
itemName: "key\"name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with single quotes",
|
||||
itemName: "key'name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backslash",
|
||||
itemName: "key\\name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with newline",
|
||||
itemName: "key\nname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with carriage return",
|
||||
itemName: "key\rname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with ampersand",
|
||||
itemName: "key&echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with redirect",
|
||||
itemName: "key>/tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with null byte",
|
||||
itemName: "key\x00name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with parentheses",
|
||||
itemName: "key(test)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with brackets",
|
||||
itemName: "key[test]",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with asterisk",
|
||||
itemName: "key*",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with question mark",
|
||||
itemName: "key?",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -6,60 +5,148 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateGPGKeyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
keyID string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"valid email address", "test@example.com", false},
|
||||
{"valid email with dots and hyphens", "test.user-name@example-domain.co.uk", false},
|
||||
{"valid email with plus", "test+tag@example.com", false},
|
||||
{"valid short key ID (8 hex chars)", "ABCDEF12", false},
|
||||
{"valid long key ID (16 hex chars)", "ABCDEF1234567890", false},
|
||||
{
|
||||
"valid fingerprint (40 hex chars)",
|
||||
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
||||
name: "valid email address",
|
||||
keyID: "test@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
"valid lowercase hex fingerprint",
|
||||
"abcdef1234567890abcdef1234567890abcdef12", false,
|
||||
name: "valid email with dots and hyphens",
|
||||
keyID: "test.user-name@example-domain.co.uk",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid email with plus",
|
||||
keyID: "test+tag@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid short key ID (8 hex chars)",
|
||||
keyID: "ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid long key ID (16 hex chars)",
|
||||
keyID: "ABCDEF1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid fingerprint (40 hex chars)",
|
||||
keyID: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase hex fingerprint",
|
||||
keyID: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case hex",
|
||||
keyID: "AbCdEf1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
{"valid mixed case hex", "AbCdEf1234567890", false},
|
||||
|
||||
// Invalid cases
|
||||
{"empty key ID", "", true},
|
||||
{"key ID with spaces", "test user@example.com", true},
|
||||
{"key ID with semicolon (command injection)", "test@example.com; rm -rf /", true},
|
||||
{
|
||||
"key ID with pipe (command injection)",
|
||||
"test@example.com | cat /etc/passwd", true,
|
||||
name: "empty key ID",
|
||||
keyID: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
|
||||
{
|
||||
"key ID with dollar sign (command injection)",
|
||||
"test@example.com$(whoami)", true,
|
||||
name: "key ID with spaces",
|
||||
keyID: "test user@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with semicolon (command injection)",
|
||||
keyID: "test@example.com; rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with pipe (command injection)",
|
||||
keyID: "test@example.com | cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backticks (command injection)",
|
||||
keyID: "test@example.com`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with dollar sign (command injection)",
|
||||
keyID: "test@example.com$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with quotes",
|
||||
keyID: "test\"@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with single quotes",
|
||||
keyID: "test'@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backslash",
|
||||
keyID: "test\\@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with newline",
|
||||
keyID: "test@example.com\nrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with carriage return",
|
||||
keyID: "test@example.com\rrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (7 chars)",
|
||||
keyID: "ABCDEF1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (9 chars)",
|
||||
keyID: "ABCDEF123",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with non-hex characters",
|
||||
keyID: "ABCDEFGH",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed format (email with hex)",
|
||||
keyID: "test@ABCDEF12",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with ampersand",
|
||||
keyID: "test@example.com & echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with redirect",
|
||||
keyID: "test@example.com > /tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with null byte",
|
||||
keyID: "test@example.com\x00",
|
||||
wantErr: true,
|
||||
},
|
||||
{"key ID with quotes", "test\"@example.com", true},
|
||||
{"key ID with single quotes", "test'@example.com", true},
|
||||
{"key ID with backslash", "test\\@example.com", true},
|
||||
{"key ID with newline", "test@example.com\nrm -rf /", true},
|
||||
{"key ID with carriage return", "test@example.com\rrm -rf /", true},
|
||||
{"hex with invalid length (7 chars)", "ABCDEF1", true},
|
||||
{"hex with invalid length (9 chars)", "ABCDEF123", true},
|
||||
{"hex with non-hex characters", "ABCDEFGH", true},
|
||||
{"mixed format (email with hex)", "test@ABCDEF12", true},
|
||||
{"key ID with ampersand", "test@example.com & echo test", true},
|
||||
{"key ID with redirect", "test@example.com > /tmp/test", true},
|
||||
{"key ID with null byte", "test@example.com\x00", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateGPGKeyID(tt.keyID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
@@ -67,3 +154,144 @@ func TestValidateGPGKeyID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "valid simple name",
|
||||
itemName: "my-secret-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with dots",
|
||||
itemName: "com.example.app.key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
itemName: "my_secret_key_123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid alphanumeric",
|
||||
itemName: "Secret123Key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with hyphen at start",
|
||||
itemName: "-my-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with dot at start",
|
||||
itemName: ".hidden-key",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "empty item name",
|
||||
itemName: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with spaces",
|
||||
itemName: "my secret key",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with semicolon",
|
||||
itemName: "key;rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with pipe",
|
||||
itemName: "key|cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backticks",
|
||||
itemName: "key`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with dollar sign",
|
||||
itemName: "key$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with quotes",
|
||||
itemName: "key\"name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with single quotes",
|
||||
itemName: "key'name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backslash",
|
||||
itemName: "key\\name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with newline",
|
||||
itemName: "key\nname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with carriage return",
|
||||
itemName: "key\rname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with ampersand",
|
||||
itemName: "key&echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with redirect",
|
||||
itemName: "key>/tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with null byte",
|
||||
itemName: "key\x00name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with parentheses",
|
||||
itemName: "key(test)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with brackets",
|
||||
itemName: "key[test]",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with asterisk",
|
||||
itemName: "key*",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with question mark",
|
||||
itemName: "key?",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -21,17 +20,12 @@ const (
|
||||
maxVersionsPerDay = 999
|
||||
)
|
||||
|
||||
var (
|
||||
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
||||
errNilValueBuffer = errors.New("value buffer is nil")
|
||||
)
|
||||
|
||||
// VersionMetadata contains information about a secret version
|
||||
type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
|
||||
}
|
||||
|
||||
// Version represents a version of a secret
|
||||
@@ -81,8 +75,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Ensure versions directory exists
|
||||
err := fs.MkdirAll(versionsDir, DirPerms)
|
||||
if err != nil {
|
||||
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
|
||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -108,11 +101,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
}
|
||||
|
||||
var serial int
|
||||
|
||||
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
|
||||
if err != nil {
|
||||
Warn("Skipping malformed version directory name",
|
||||
"name", entry.Name(), "error", err)
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -125,7 +115,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
// Generate new version name
|
||||
newSerial := maxSerial + 1
|
||||
if newSerial > maxVersionsPerDay {
|
||||
return "", errMaxVersionsPerDay
|
||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
@@ -134,7 +124,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
// Save saves the version metadata and value
|
||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
if value == nil {
|
||||
return errNilValueBuffer
|
||||
return fmt.Errorf("value buffer is nil")
|
||||
}
|
||||
|
||||
DebugWith("Saving secret version",
|
||||
@@ -146,16 +136,14 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create version directory
|
||||
err := fs.MkdirAll(sv.Directory, DirPerms)
|
||||
if err != nil {
|
||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate a new keypair for this version
|
||||
// Step 1: Generate a new keypair for this version
|
||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||
@@ -163,33 +151,110 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||
}
|
||||
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
// Store private key in memguard buffer immediately
|
||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
|
||||
[]byte(versionIdentity.String()))
|
||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivateKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||
slog.String("public_key", versionPublicKey),
|
||||
)
|
||||
|
||||
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
|
||||
if err != nil {
|
||||
return err
|
||||
// Step 2: Store the version's public key
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
|
||||
// Step 3: Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedMetadata(fs, versionIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
// Step 4: Store the encrypted value
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version",
|
||||
"version", sv.Version, "secret_name", sv.SecretName)
|
||||
// Step 5: Get vault's long-term public key for encrypting the version's private key
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", 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
|
||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Store the encrypted private key
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Encrypt and store metadata
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -205,11 +270,9 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
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)
|
||||
}
|
||||
@@ -233,11 +296,9 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 4: Read encrypted metadata
|
||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
|
||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||
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)
|
||||
}
|
||||
@@ -253,25 +314,20 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 6: Unmarshal metadata
|
||||
var metadata VersionMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
|
||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
sv.Metadata = metadata
|
||||
|
||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the version value
|
||||
func (sv *Version) GetValue(
|
||||
ltIdentity *age.X25519Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting version value",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
@@ -289,22 +345,16 @@ func (sv *Version) GetValue(
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
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
|
||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||
|
||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
@@ -312,9 +362,7 @@ func (sv *Version) GetValue(
|
||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
Debug("Successfully decrypted version private key",
|
||||
"version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||
@@ -327,21 +375,16 @@ func (sv *Version) GetValue(
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||
|
||||
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||
@@ -357,139 +400,6 @@ func (sv *Version) GetValue(
|
||||
return valueBuffer, nil
|
||||
}
|
||||
|
||||
// writePublicKeyAndValue stores the version's public key and the value
|
||||
// encrypted to it.
|
||||
func (sv *Version) writePublicKeyAndValue(
|
||||
fs afero.Fs,
|
||||
versionIdentity *age.X25519Identity,
|
||||
value *memguard.LockedBuffer,
|
||||
) error {
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
|
||||
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEncryptedPrivateKey encrypts the version's private key to the
|
||||
// vault's long-term public key and stores it.
|
||||
func (sv *Version) writeEncryptedPrivateKey(
|
||||
fs afero.Fs,
|
||||
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
||||
) error {
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Encrypting version private key to long-term public key",
|
||||
"version", sv.Version)
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key",
|
||||
"error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
|
||||
if err != nil {
|
||||
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 nil
|
||||
}
|
||||
|
||||
// writeEncryptedMetadata encrypts the version metadata to the version's
|
||||
// public key and stores it.
|
||||
func (sv *Version) writeEncryptedMetadata(
|
||||
fs afero.Fs,
|
||||
versionIdentity *age.X25519Identity,
|
||||
) error {
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(
|
||||
metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
|
||||
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version metadata",
|
||||
"error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
@@ -499,7 +409,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -511,7 +420,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
}
|
||||
|
||||
var versions []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
versions = append(versions, entry.Name())
|
||||
@@ -548,8 +456,7 @@ func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||
_ = fs.Remove(currentPath)
|
||||
|
||||
// Write just the version name to the file
|
||||
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to create current version file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,32 +32,22 @@
|
||||
// - Long-term key required for all operations
|
||||
// - Concurrent reads handled safely
|
||||
|
||||
package secret_test
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecretDir = "/test/secret"
|
||||
testVaultName = "test"
|
||||
testVaultStateDir = "/test"
|
||||
)
|
||||
|
||||
var errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
|
||||
// MockVersionVault implements VaultInterface for testing
|
||||
// MockVault implements VaultInterface for testing
|
||||
type MockVersionVault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
@@ -70,37 +60,31 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||
return errNotImplementedInMock
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*secret.PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func TestGenerateVersionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// Test first version generation
|
||||
version1, err := secret.GenerateVersionName(fs, secretDir)
|
||||
version1, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||
|
||||
@@ -110,7 +94,7 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := secret.GenerateVersionName(fs, secretDir)
|
||||
version2, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
@@ -120,10 +104,8 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Create 999 versions
|
||||
@@ -135,22 +117,20 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
}
|
||||
|
||||
// Try to create one more - should fail
|
||||
_, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.Error(t, err)
|
||||
_, err := GenerateVersionName(fs, secretDir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
@@ -160,13 +140,11 @@ func TestNewVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
@@ -177,21 +155,18 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
// Generate and store long-term public key
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -203,13 +178,11 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
@@ -219,16 +192,14 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
@@ -236,12 +207,11 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv2 := NewVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -253,13 +223,11 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
@@ -269,77 +237,64 @@ func TestSecretVersionGetValue(t *testing.T) {
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
expectedValue := make([]byte, len(originalValue))
|
||||
copy(expectedValue, originalValue)
|
||||
|
||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||
defer originalBuffer.Destroy()
|
||||
|
||||
err = sv.Save(originalBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedBuffer.Destroy()
|
||||
|
||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
|
||||
// Create some versions
|
||||
testVersions := []string{
|
||||
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
|
||||
}
|
||||
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(
|
||||
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
versions, err = ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be sorted in reverse chronological order
|
||||
expected := []string{
|
||||
"20231216.001", "20231215.002", "20231215.001", "20231214.001",
|
||||
}
|
||||
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
|
||||
assert.Equal(t, expected, versions)
|
||||
}
|
||||
|
||||
func TestGetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// The current file contains just the version name
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
@@ -349,43 +304,39 @@ func TestGetCurrentVersion(t *testing.T) {
|
||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
secretDir := "/test/secret"
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = secret.GetCurrentVersion(fs, secretDir)
|
||||
version, err = GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231217.001", version)
|
||||
}
|
||||
|
||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that all timestamp fields behave consistently as pointers
|
||||
vm := secret.VersionMetadata{
|
||||
vm := VersionMetadata{
|
||||
ID: "test-id",
|
||||
}
|
||||
|
||||
@@ -417,6 +368,5 @@ func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package vault
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by vault operations.
|
||||
//
|
||||
// Several of these carry deliberately partial text: the message a caller
|
||||
// composes with fmt.Errorf places the interpolated value where it has
|
||||
// always appeared, and the sentinel supplies only the surrounding fixed
|
||||
// words. This keeps every composed message byte-identical to the dynamic
|
||||
// errors these sentinels replaced. Each such sentinel notes the message it
|
||||
// participates in.
|
||||
var (
|
||||
// ErrMnemonicMismatch indicates the mnemonic-derived public key does
|
||||
// not match the vault's stored public key hash.
|
||||
ErrMnemonicMismatch = errors.New(
|
||||
"derived public key does not match vault: mnemonic may be incorrect",
|
||||
)
|
||||
|
||||
// ErrInvalidVaultName indicates a vault name that does not match the
|
||||
// allowed pattern [a-z0-9.\-_]+. Composed as
|
||||
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
|
||||
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||
|
||||
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||
// as "vault <name> does not exist".
|
||||
ErrVaultNotFound = errors.New("does not exist")
|
||||
|
||||
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
||||
ErrNilValueBuffer = errors.New("value buffer is nil")
|
||||
|
||||
// ErrInvalidSecretName indicates a secret name that does not match
|
||||
// the allowed pattern [a-z0-9.\-_/]+. Composed as
|
||||
// "invalid secret name '<name>': must match pattern [a-z0-9.\-_/]+",
|
||||
// or as "invalid secret name: <name>" by GetSecretObject.
|
||||
ErrInvalidSecretName = errors.New("invalid secret name")
|
||||
|
||||
// ErrSecretExists indicates the secret already exists and --force
|
||||
// was not supplied. Composed as
|
||||
// "secret <name> already exists (use --force to overwrite)", or as
|
||||
// "secret '<name>' already exists in vault '<vault>' (use --force to
|
||||
// overwrite)" when copying between vaults.
|
||||
ErrSecretExists = errors.New("already exists")
|
||||
|
||||
// ErrSecretNotFound indicates the named secret does not exist.
|
||||
// Composed as "secret <name> not found".
|
||||
ErrSecretNotFound = errors.New("not found")
|
||||
|
||||
// ErrVersionNotFound indicates the requested secret version does not
|
||||
// exist. Composed as
|
||||
// "version <version> not found for secret <name>".
|
||||
ErrVersionNotFound = errors.New("not found for secret")
|
||||
|
||||
// ErrNoVersions indicates the source secret has no versions. Composed
|
||||
// as "source secret '<name>' has no versions".
|
||||
ErrNoVersions = errors.New("has no versions")
|
||||
|
||||
// ErrUnsupportedUnlockerType indicates an unlocker metadata type
|
||||
// that this build does not support.
|
||||
ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||
|
||||
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
|
||||
// Composed as "unlocker with ID <id> not found".
|
||||
ErrUnlockerNotFound = errors.New("not found")
|
||||
)
|
||||
@@ -3,10 +3,8 @@ package vault_test
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
@@ -14,33 +12,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// deriveVaultIdentity derives the long-term identity for the given vault
|
||||
// from testMnemonic using the derivation index stored in its metadata.
|
||||
func deriveVaultIdentity(
|
||||
t *testing.T, fs afero.Fs, vlt *vault.Vault,
|
||||
) *age.X25519Identity {
|
||||
t.Helper()
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
return ltIdentity
|
||||
}
|
||||
|
||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Create a temporary directory for our tests
|
||||
tempDir := t.TempDir()
|
||||
@@ -48,410 +19,398 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test mnemonic
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Set test environment variables
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Test currentvault file handling (plain file with relative path)
|
||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||
testCurrentVaultFileHandling(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Verify the currentvault file exists and contains just the vault name
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
currentVaultContents, err := os.ReadFile(currentVaultPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||
}
|
||||
|
||||
expectedVaultName := "test-vault"
|
||||
if string(currentVaultContents) != expectedVaultName {
|
||||
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents))
|
||||
}
|
||||
|
||||
// Test that ResolveVaultSymlink correctly resolves the path
|
||||
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
||||
}
|
||||
|
||||
if resolvedPath != vaultDir {
|
||||
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||
}
|
||||
})
|
||||
|
||||
// Test secret operations with deeply nested paths
|
||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||
testDeepPathSecrets(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic using the vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a secret with a deeply nested path
|
||||
deepPath := "api/credentials/production/database/primary"
|
||||
secretValue := []byte("supersecretdbpassword")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||
}
|
||||
|
||||
// List secrets and verify our deep path secret is there
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range secrets {
|
||||
if s == deepPath {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Deep path secret not found in listed secrets")
|
||||
}
|
||||
|
||||
// Retrieve the secret and verify its value
|
||||
retrievedValue, err := vlt.GetSecret(deepPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||
string(expectedValue), string(retrievedValue))
|
||||
}
|
||||
})
|
||||
|
||||
// Test key caching in GetOrDeriveLongTermKey
|
||||
t.Run("KeyCaching", func(t *testing.T) {
|
||||
testKeyCaching(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic for verification using the vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is locked initially
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked initially")
|
||||
}
|
||||
|
||||
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
||||
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is now unlocked
|
||||
if vlt.Locked() {
|
||||
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
||||
}
|
||||
|
||||
// Second call should return the cached key without re-deriving
|
||||
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cached long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify both keys are the same instance
|
||||
if firstKey != secondKey {
|
||||
t.Errorf("Second key call should return same instance as first call")
|
||||
}
|
||||
|
||||
// Verify the public key matches what we expect
|
||||
expectedPubKey := ltIdentity.Recipient().String()
|
||||
actualPubKey := firstKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||
}
|
||||
|
||||
// Now clear the key and verify it's locked again
|
||||
vlt.ClearLongTermKey()
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked after clearing key")
|
||||
}
|
||||
|
||||
// Get the key again and verify it works
|
||||
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the public key still matches
|
||||
actualPubKey = thirdKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Re-derived public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Test vault name validation
|
||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||
testVaultNameValidation(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Test valid vault names
|
||||
validNames := []string{
|
||||
"default",
|
||||
"test-vault",
|
||||
"production.vault",
|
||||
"vault_123",
|
||||
"a-very-long-vault-name-with-dashes",
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid vault names
|
||||
invalidNames := []string{
|
||||
"", // Empty
|
||||
"UPPERCASE", // Uppercase not allowed
|
||||
"invalid/name", // Slashes not allowed in vault names
|
||||
"invalid name", // Spaces not allowed
|
||||
"invalid@name", // Special chars not allowed
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error creating vault with invalid name %q, but got none", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test multiple vaults and switching between them
|
||||
t.Run("MultipleVaults", func(t *testing.T) {
|
||||
testMultipleVaults(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create three vaults
|
||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||
for _, name := range vaultNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// List vaults and verify all three are there
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
if len(vaults) != 3 {
|
||||
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
||||
}
|
||||
|
||||
// Test switching between vaults
|
||||
for _, name := range vaultNames {
|
||||
// Select the vault
|
||||
if err := vault.SelectVault(fs, stateDir, name); err != nil {
|
||||
t.Fatalf("Failed to select vault %s: %v", name, err)
|
||||
}
|
||||
|
||||
// Get current vault and verify it's the one we selected
|
||||
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault after selecting %s: %v", name, err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != name {
|
||||
t.Errorf("Expected current vault to be %s, got %s", name, currentVault.GetName())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test adding a secret in one vault and verifying it's not visible in
|
||||
// another
|
||||
// Test adding a secret in one vault and verifying it's not visible in another
|
||||
t.Run("VaultIsolation", func(t *testing.T) {
|
||||
testVaultIsolation(t, fs, tempDir)
|
||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create two vaults - CreateVault now handles public key when mnemonic is in env
|
||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault1: %v", err)
|
||||
}
|
||||
|
||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault2: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic
|
||||
// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex
|
||||
|
||||
// Load vault1 metadata to get its derivation index
|
||||
vault1Dir, err := vault1.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault1 directory: %v", err)
|
||||
}
|
||||
vault1Metadata, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||
}
|
||||
|
||||
ltIdentity1, err := agehd.DeriveIdentity(testMnemonic, vault1Metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key for vault1: %v", err)
|
||||
}
|
||||
|
||||
// Load vault2 metadata to get its derivation index
|
||||
vault2Dir, err := vault2.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault2 directory: %v", err)
|
||||
}
|
||||
vault2Metadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||
}
|
||||
|
||||
ltIdentity2, err := agehd.DeriveIdentity(testMnemonic, vault2Metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key for vault2: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vaults with their respective keys
|
||||
vault1.Unlock(ltIdentity1)
|
||||
vault2.Unlock(ltIdentity2)
|
||||
|
||||
// Add a secret to vault1
|
||||
secretName := "test-secret"
|
||||
secretValue := []byte("secret in vault1")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Verify the secret exists in vault1
|
||||
vault1Secrets, err := vault1.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range vault1Secrets {
|
||||
if s == secretName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Secret not found in vault1")
|
||||
}
|
||||
|
||||
// Verify the secret does NOT exist in vault2
|
||||
vault2Secrets, err := vault2.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
||||
}
|
||||
|
||||
found = false
|
||||
for _, s := range vault2Secrets {
|
||||
if s == secretName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
t.Errorf("Secret from vault1 should not be visible in vault2")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Verify the currentvault file exists and contains just the vault name
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||
}
|
||||
|
||||
if string(currentVaultContents) != testVaultName {
|
||||
t.Errorf("Expected currentvault to contain %q, got %q",
|
||||
testVaultName, string(currentVaultContents))
|
||||
}
|
||||
|
||||
// Test that ResolveVaultSymlink correctly resolves the path
|
||||
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
||||
}
|
||||
|
||||
if resolvedPath != vaultDir {
|
||||
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault now handles public key when
|
||||
// mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic using the vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a secret with a deeply nested path
|
||||
deepPath := "api/credentials/production/database/primary"
|
||||
secretValue := []byte("supersecretdbpassword")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||
}
|
||||
|
||||
// List secrets and verify our deep path secret is there
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(secrets, deepPath) {
|
||||
t.Errorf("Deep path secret not found in listed secrets")
|
||||
}
|
||||
|
||||
// Retrieve the secret and verify its value
|
||||
retrievedValue, err := vlt.GetSecret(deepPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||
string(expectedValue), string(retrievedValue))
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault now handles public key when
|
||||
// mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic for verification using the
|
||||
// vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is locked initially
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked initially")
|
||||
}
|
||||
|
||||
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
||||
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is now unlocked
|
||||
if vlt.Locked() {
|
||||
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
||||
}
|
||||
|
||||
// Second call should return the cached key without re-deriving
|
||||
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cached long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify both keys are the same instance
|
||||
if firstKey != secondKey {
|
||||
t.Errorf("Second key call should return same instance as first call")
|
||||
}
|
||||
|
||||
// Verify the public key matches what we expect
|
||||
expectedPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
actualPubKey := firstKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
|
||||
// Now clear the key and verify it's locked again
|
||||
vlt.ClearLongTermKey()
|
||||
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked after clearing key")
|
||||
}
|
||||
|
||||
// Get the key again and verify it works
|
||||
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the public key still matches
|
||||
actualPubKey = thirdKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Test valid vault names
|
||||
validNames := []string{
|
||||
"default",
|
||||
"test-vault",
|
||||
"production.vault",
|
||||
"vault_123",
|
||||
"a-very-long-vault-name-with-dashes",
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid vault names
|
||||
invalidNames := []string{
|
||||
"", // Empty
|
||||
"UPPERCASE", // Uppercase not allowed
|
||||
"invalid/name", // Slashes not allowed in vault names
|
||||
"invalid name", // Spaces not allowed
|
||||
"invalid@name", // Special chars not allowed
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error creating vault with invalid name %q, "+
|
||||
"but got none", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create three vaults
|
||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||
for _, name := range vaultNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// List vaults and verify all three are there
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
if len(vaults) != 3 {
|
||||
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
||||
}
|
||||
|
||||
// Test switching between vaults
|
||||
for _, name := range vaultNames {
|
||||
// Select the vault
|
||||
err := vault.SelectVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault %s: %v", name, err)
|
||||
}
|
||||
|
||||
// Get current vault and verify it's the one we selected
|
||||
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault after selecting %s: %v",
|
||||
name, err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != name {
|
||||
t.Errorf("Expected current vault to be %s, got %s",
|
||||
name, currentVault.GetName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create two vaults - CreateVault now handles public key when mnemonic
|
||||
// is in env
|
||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault1: %v", err)
|
||||
}
|
||||
|
||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault2: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term keys from mnemonic
|
||||
// Note: Both vaults will have different derivation indexes due to
|
||||
// GetNextDerivationIndex
|
||||
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
|
||||
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
|
||||
|
||||
// Unlock the vaults with their respective keys
|
||||
vault1.Unlock(ltIdentity1)
|
||||
vault2.Unlock(ltIdentity2)
|
||||
|
||||
// Add a secret to vault1
|
||||
secretValue := []byte("secret in vault1")
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vault1.AddSecret(testSecretName, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||
}
|
||||
|
||||
// Verify the secret exists in vault1
|
||||
vault1Secrets, err := vault1.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(vault1Secrets, testSecretName) {
|
||||
t.Errorf("Secret not found in vault1")
|
||||
}
|
||||
|
||||
// Verify the secret does NOT exist in vault2
|
||||
vault2Secrets, err := vault2.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
||||
}
|
||||
|
||||
if slices.Contains(vault2Secrets, testSecretName) {
|
||||
t.Errorf("Secret from vault1 should not be visible in vault2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,14 @@
|
||||
// - Consistent test mnemonic for reproducible keys
|
||||
// - Proper cleanup and isolation between tests
|
||||
|
||||
//nolint:testpackage // uses white-box test helpers shared with this package
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -38,33 +35,38 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// errUnexpectedValue is returned by concurrent readers when a secret value
|
||||
// does not match the expected contents.
|
||||
var errUnexpectedValue = errors.New("unexpected value")
|
||||
// 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
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvMnemonic,
|
||||
"abandon abandon abandon abandon abandon abandon "+
|
||||
"abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vault, err := CreateVault(fs, testStateDir, "test")
|
||||
vault, err := CreateVault(fs, stateDir, "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault
|
||||
@@ -74,289 +76,225 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
|
||||
// Step 1: Create initial version
|
||||
t.Run("create_initial_version", func(t *testing.T) {
|
||||
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
addTestSecret(t, vault, secretName, []byte("version-1-data"), false)
|
||||
|
||||
// Verify secret can be retrieved
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify version directory structure
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 1)
|
||||
|
||||
// Verify current symlink exists
|
||||
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, versions[0], currentVersion)
|
||||
|
||||
// Verify metadata
|
||||
version := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||
assert.NotNil(t, version.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||
assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version
|
||||
})
|
||||
|
||||
// Step 2: Create second version
|
||||
var firstVersionName string
|
||||
t.Run("create_second_version", func(t *testing.T) {
|
||||
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
// Small delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get first version name before creating second
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
firstVersionName = versions[0]
|
||||
|
||||
// Create second version
|
||||
addTestSecret(t, vault, secretName, []byte("version-2-data"), true)
|
||||
|
||||
// Verify new value is current
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value)
|
||||
|
||||
// Verify we now have two versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 2)
|
||||
|
||||
// Verify first version metadata was updated with notAfter
|
||||
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
|
||||
// Verify second version metadata
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||
|
||||
// NotBefore of second should equal NotAfter of first
|
||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix())
|
||||
})
|
||||
|
||||
// Step 3: Create third version
|
||||
t.Run("create_third_version", func(t *testing.T) {
|
||||
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vault, secretName, []byte("version-3-data"), true)
|
||||
|
||||
// Verify we now have three versions
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 3)
|
||||
|
||||
// Current should be version-3
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value)
|
||||
})
|
||||
|
||||
// Step 4: Retrieve specific versions
|
||||
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
||||
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
|
||||
// Get each version by its name
|
||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value1)
|
||||
|
||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value2)
|
||||
|
||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value3)
|
||||
|
||||
// Empty version should return current
|
||||
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||
})
|
||||
|
||||
// Step 5: Promote old version to current
|
||||
t.Run("promote_old_version", func(t *testing.T) {
|
||||
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Promote the first version (oldest) to current
|
||||
oldestVersion := versions[2]
|
||||
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify current now returns the old version's value
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify the version metadata hasn't changed
|
||||
// (promoting shouldn't modify timestamps)
|
||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter
|
||||
})
|
||||
|
||||
// Step 6: Test version limits
|
||||
t.Run("version_serial_limits", func(t *testing.T) {
|
||||
testVersionSerialLimits(t, fs, vault, vaultDir)
|
||||
// Create a new secret for this test
|
||||
limitSecretName := "limit/test"
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||
|
||||
// Create 998 versions (we already have one from the first AddSecret)
|
||||
addTestSecret(t, vault, limitSecretName, []byte("initial"), false)
|
||||
|
||||
// Get today's date for consistent version names
|
||||
today := time.Now().Format("20060102")
|
||||
|
||||
// Manually create many versions with same date
|
||||
for i := 2; i <= 998; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
versionDir := filepath.Join(secretDir, versionName)
|
||||
err := fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should be able to create one more (999)
|
||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
|
||||
|
||||
// Create the 999th version directory
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to create 1000th version
|
||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
})
|
||||
|
||||
// Step 7: Test error cases
|
||||
t.Run("error_cases", func(t *testing.T) {
|
||||
testVersionErrorCases(t, vault, secretName)
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
|
||||
// Try to get version of non-existent secret
|
||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Try to add secret without force when it exists
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||
defer failBuffer.Destroy()
|
||||
err = vault.AddSecret(secretName, failBuffer, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
}
|
||||
|
||||
func testCreateInitialVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1-data"), false)
|
||||
|
||||
// Verify secret can be retrieved
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify version directory structure
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 1)
|
||||
|
||||
// Verify current symlink exists
|
||||
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, versions[0], currentVersion)
|
||||
|
||||
// Verify metadata
|
||||
version := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||
assert.NotNil(t, version.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||
// NotAfter should be nil for current version
|
||||
assert.Nil(t, version.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func testCreateSecondVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Small delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get first version name before creating second
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstVersionName := versions[0]
|
||||
|
||||
// Create second version
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
|
||||
|
||||
// Verify new value is current
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value)
|
||||
|
||||
// Verify we now have two versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 2)
|
||||
|
||||
// Verify first version metadata was updated with notAfter
|
||||
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
|
||||
// Verify second version metadata
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||
|
||||
// NotBefore of second should equal NotAfter of first
|
||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
|
||||
secondVersion.Metadata.NotBefore.Unix())
|
||||
}
|
||||
|
||||
func testCreateThirdVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
|
||||
|
||||
// Verify we now have three versions
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 3)
|
||||
|
||||
// Current should be version-3
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value)
|
||||
}
|
||||
|
||||
func testRetrieveSpecificVersions(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
|
||||
// Get each version by its name
|
||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value1)
|
||||
|
||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value2)
|
||||
|
||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value3)
|
||||
|
||||
// Empty version should return current
|
||||
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||
}
|
||||
|
||||
func testPromoteOldVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Promote the first version (oldest) to current
|
||||
oldestVersion := versions[2]
|
||||
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify current now returns the old version's value
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify the version metadata hasn't changed
|
||||
// (promoting shouldn't modify timestamps)
|
||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
// should still have its old notAfter
|
||||
assert.NotNil(t, version.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func testVersionSerialLimits(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Create a new secret for this test
|
||||
limitSecretName := "limit/test"
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||
|
||||
// Create 998 versions (we already have one from the first AddSecret)
|
||||
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
|
||||
|
||||
// Get today's date for consistent version names
|
||||
today := time.Now().Format("20060102")
|
||||
|
||||
// Manually create many versions with same date
|
||||
for i := 2; i <= 998; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
versionDir := filepath.Join(secretDir, versionName)
|
||||
err := fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should be able to create one more (999)
|
||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, today+".999", versionName)
|
||||
|
||||
// Create the 999th version directory
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to create 1000th version
|
||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
|
||||
// Try to get version of non-existent secret
|
||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||
require.Error(t, err)
|
||||
|
||||
// Try to add secret without force when it exists
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||
defer failBuffer.Destroy()
|
||||
|
||||
err = vault.AddSecret(secretName, failBuffer, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
}
|
||||
|
||||
// TestVersionConcurrency tests concurrent version operations
|
||||
//
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVersionConcurrency(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "concurrent/test"
|
||||
|
||||
// Create initial version
|
||||
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
|
||||
addTestSecret(t, vault, secretName, []byte("initial"), false)
|
||||
|
||||
// Test concurrent reads
|
||||
t.Run("concurrent_reads", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
errCh := make(chan error, 10)
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for range 10 {
|
||||
go func() {
|
||||
value, err := vault.GetSecret(secretName)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
errors <- err
|
||||
} else if string(value) != "initial" {
|
||||
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
|
||||
errors <- fmt.Errorf("unexpected value: %s", value)
|
||||
}
|
||||
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
@@ -368,7 +306,7 @@ func TestVersionConcurrency(t *testing.T) {
|
||||
|
||||
// Check for errors
|
||||
select {
|
||||
case err := <-errCh:
|
||||
case err := <-errors:
|
||||
t.Fatalf("concurrent read failed: %v", err)
|
||||
default:
|
||||
// No errors
|
||||
@@ -377,14 +315,12 @@ func TestVersionConcurrency(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestVersionCompatibility tests that old secrets without versions still work
|
||||
//
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVersionCompatibility(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -397,12 +333,9 @@ func TestVersionCompatibility(t *testing.T) {
|
||||
|
||||
// Create old-style encrypted value directly in secret directory
|
||||
testValue := []byte("legacy-value")
|
||||
|
||||
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testValueBuffer.Destroy()
|
||||
|
||||
ltRecipient := ltIdentity.Recipient()
|
||||
|
||||
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -412,7 +345,7 @@ func TestVersionCompatibility(t *testing.T) {
|
||||
|
||||
// Should fail to get with version-aware methods
|
||||
_, err = vault.GetSecret(secretName)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
|
||||
// List versions should return empty
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
|
||||
@@ -15,13 +15,10 @@ import (
|
||||
)
|
||||
|
||||
// Register the GetCurrentVault function with the secret package
|
||||
//
|
||||
//nolint:gochecknoinits // registers the vault accessor with the secret package
|
||||
func init() {
|
||||
secret.RegisterGetCurrentVaultFunc(
|
||||
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||
return GetCurrentVault(fs, stateDir)
|
||||
})
|
||||
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||
return GetCurrentVault(fs, stateDir)
|
||||
})
|
||||
}
|
||||
|
||||
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
||||
@@ -30,7 +27,6 @@ func isValidVaultName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||
|
||||
return matched
|
||||
@@ -69,11 +65,9 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||
|
||||
_, err := fs.Stat(currentVaultPath)
|
||||
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)
|
||||
}
|
||||
@@ -82,7 +76,6 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
|
||||
// Resolve the symlink to get the actual vault directory
|
||||
secret.Debug("Resolving vault symlink")
|
||||
|
||||
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,8 +88,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
vaultName := filepath.Base(targetPath)
|
||||
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
||||
|
||||
secret.Debug("Current vault resolved",
|
||||
"vault_name", vaultName, "target_path", targetPath)
|
||||
secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath)
|
||||
|
||||
// Create and return the vault
|
||||
return NewVault(fs, stateDir, vaultName), nil
|
||||
@@ -111,7 +103,6 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -124,7 +115,6 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
|
||||
// Extract vault names
|
||||
var vaults []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
vaults = append(vaults, entry.Name())
|
||||
@@ -134,26 +124,22 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||
// It returns the derivation index, public key hash, and family hash.
|
||||
func processMnemonicForVault(
|
||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||
) (uint32, string, string, error) {
|
||||
// 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)
|
||||
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)
|
||||
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)
|
||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||
if err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
@@ -166,18 +152,14 @@ func processMnemonicForVault(
|
||||
|
||||
// Write the public key
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
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()))
|
||||
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
|
||||
@@ -185,8 +167,7 @@ func processMnemonicForVault(
|
||||
if err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
return derivationIndex, publicKeyHash, familyHash, nil
|
||||
}
|
||||
@@ -199,12 +180,8 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
if !isValidVaultName(name) {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||
ErrInvalidVaultName, 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)
|
||||
|
||||
// Create vault directory structure
|
||||
@@ -212,30 +189,24 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||
|
||||
// Create main vault directory
|
||||
err := fs.MkdirAll(vaultDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Create secrets directory
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
if err := fs.MkdirAll(secretsDir, secret.DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
||||
}
|
||||
|
||||
// Create unlockers directory
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
// Process mnemonic if available
|
||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
||||
fs, stateDir, vaultDir, name)
|
||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -247,17 +218,13 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
PublicKeyHash: publicKeyHash,
|
||||
MnemonicFamilyHash: familyHash,
|
||||
}
|
||||
|
||||
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
||||
if err != nil {
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
|
||||
// Select the newly created vault as current
|
||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||
|
||||
err = SelectVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
if err := SelectVault(fs, stateDir, name); err != nil {
|
||||
return nil, fmt.Errorf("failed to select vault: %w", err)
|
||||
}
|
||||
|
||||
@@ -275,42 +242,32 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
if !isValidVaultName(name) {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||
ErrInvalidVaultName, name,
|
||||
)
|
||||
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||
}
|
||||
|
||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||
|
||||
// Check if vault exists
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
||||
return fmt.Errorf("vault %s does not exist", name)
|
||||
}
|
||||
|
||||
// Create or update the currentvault file with just the vault name
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
// Remove existing file if it exists
|
||||
_, err = fs.Stat(currentVaultPath)
|
||||
if err == nil {
|
||||
if _, err := fs.Stat(currentVaultPath); err == nil {
|
||||
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
||||
|
||||
_ = fs.Remove(currentVaultPath)
|
||||
}
|
||||
|
||||
// Write just the vault name to the file
|
||||
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to select vault: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,15 +34,12 @@ func ComputeDoubleSHA256(data []byte) string {
|
||||
|
||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
||||
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||
func GetNextDerivationIndex(
|
||||
fs afero.Fs, stateDir string, mnemonic string,
|
||||
) (uint32, error) {
|
||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint32, error) {
|
||||
// First, derive the public key for index 0 to get our identifier
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||
@@ -52,7 +49,6 @@ func GetNextDerivationIndex(
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// No vaults yet, start with index 0
|
||||
return 0, nil
|
||||
@@ -74,7 +70,6 @@ func GetNextDerivationIndex(
|
||||
|
||||
// Try to read vault metadata
|
||||
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
// Skip vaults without metadata
|
||||
@@ -82,9 +77,7 @@ func GetNextDerivationIndex(
|
||||
}
|
||||
|
||||
var metadata Metadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
// Skip vaults with invalid metadata
|
||||
continue
|
||||
}
|
||||
@@ -113,8 +106,7 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
||||
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to write vault metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -131,9 +123,7 @@ func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
var metadata Metadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,243 +1,208 @@
|
||||
package vault_test
|
||||
package vault
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
||||
func TestVaultMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Test mnemonic for consistent testing
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
||||
testComputeDoubleSHA256(t)
|
||||
// Test data
|
||||
data := []byte("test data")
|
||||
hash := ComputeDoubleSHA256(data)
|
||||
|
||||
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
||||
}
|
||||
|
||||
// Verify consistency
|
||||
hash2 := ComputeDoubleSHA256(data)
|
||||
if hash != hash2 {
|
||||
t.Errorf("Hash should be consistent for same input")
|
||||
}
|
||||
|
||||
// Verify different input produces different hash
|
||||
hash3 := ComputeDoubleSHA256([]byte("different data"))
|
||||
if hash == hash3 {
|
||||
t.Errorf("Different input should produce different hash")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
||||
testGetNextDerivationIndex(t, fs)
|
||||
// Test with no existing vaults
|
||||
index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||
}
|
||||
|
||||
// Create a vault with metadata and matching public key
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
|
||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for index 0
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKeyHash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
metadata1 := &Metadata{
|
||||
DerivationIndex: 0,
|
||||
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
|
||||
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Next index for same mnemonic should be 1
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
||||
}
|
||||
|
||||
// Different mnemonic should start at 0
|
||||
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d", index)
|
||||
}
|
||||
|
||||
// Add another vault with same mnemonic but higher index
|
||||
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2")
|
||||
if err := fs.MkdirAll(vaultDir2, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for index 5
|
||||
identity5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
pubKey5 := identity5.Recipient().String()
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
// Compute the hash for index 5 key
|
||||
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
|
||||
|
||||
metadata2 := &Metadata{
|
||||
DerivationIndex: 5,
|
||||
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
|
||||
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Next index should be 1 (not 6) because we look for the first available slot
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MetadataPersistence", func(t *testing.T) {
|
||||
testMetadataPersistence(t, fs)
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault")
|
||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Create and save metadata
|
||||
metadata := &Metadata{
|
||||
DerivationIndex: 3,
|
||||
PublicKeyHash: "test-public-key-hash",
|
||||
}
|
||||
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
loaded, err := LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load metadata: %v", err)
|
||||
}
|
||||
|
||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
|
||||
}
|
||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s", metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
||||
testDifferentKeysForDifferentIndices(t)
|
||||
// Derive keys with different indices
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
||||
}
|
||||
|
||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
||||
}
|
||||
|
||||
// Compute public key hashes
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKey1 := identity1.Recipient().String()
|
||||
hash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Verify different indices produce different public keys
|
||||
if pubKey0 == pubKey1 {
|
||||
t.Errorf("Different derivation indices should produce different public keys")
|
||||
}
|
||||
|
||||
// But the hash of index 0's public key should be the same for the same mnemonic
|
||||
// This is what we use as the identifier
|
||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
pubKey0Again := identity0Again.Recipient().String()
|
||||
hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||
|
||||
if hash0 != hash0Again {
|
||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testComputeDoubleSHA256(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Test data
|
||||
data := []byte("test data")
|
||||
hash := vault.ComputeDoubleSHA256(data)
|
||||
|
||||
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
||||
}
|
||||
|
||||
// Verify consistency
|
||||
hash2 := vault.ComputeDoubleSHA256(data)
|
||||
if hash != hash2 {
|
||||
t.Errorf("Hash should be consistent for same input")
|
||||
}
|
||||
|
||||
// Verify different input produces different hash
|
||||
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
|
||||
if hash == hash3 {
|
||||
t.Errorf("Different input should produce different hash")
|
||||
}
|
||||
}
|
||||
|
||||
// createVaultDirWithMetadata creates a vault directory containing a public
|
||||
// key derived from testMnemonic at the given index plus saved metadata, and
|
||||
// returns the derived public key hash. An empty familyHash defaults to the
|
||||
// derived key's own hash.
|
||||
func createVaultDirWithMetadata(
|
||||
t *testing.T, fs afero.Fs, vaultName string,
|
||||
derivationIndex uint32, familyHash string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
|
||||
|
||||
err := fs.MkdirAll(vaultDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for the requested index
|
||||
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
|
||||
pubKey := identity.Recipient().String()
|
||||
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
|
||||
|
||||
// Write public key
|
||||
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
||||
[]byte(pubKey), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
if familyHash == "" {
|
||||
familyHash = pubKeyHash
|
||||
}
|
||||
|
||||
metadata := &vault.Metadata{
|
||||
DerivationIndex: derivationIndex,
|
||||
PublicKeyHash: pubKeyHash,
|
||||
MnemonicFamilyHash: familyHash,
|
||||
}
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
return pubKeyHash
|
||||
}
|
||||
|
||||
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
// Test with no existing vaults
|
||||
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||
}
|
||||
|
||||
// Create a vault with metadata and matching public key (index 0; the
|
||||
// family hash is the index 0 key hash)
|
||||
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
|
||||
|
||||
// Next index for same mnemonic should be 1
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
||||
}
|
||||
|
||||
// Different mnemonic should start at 0
|
||||
//nolint:dupword // BIP39-style test mnemonic
|
||||
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
|
||||
index)
|
||||
}
|
||||
|
||||
// Add another vault with same mnemonic but higher index (5), sharing
|
||||
// the same family hash since it's from the same mnemonic
|
||||
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
|
||||
|
||||
// Next index should be 1 (not 6): we look for the first available slot
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
|
||||
err := fs.MkdirAll(vaultDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Create and save metadata
|
||||
metadata := &vault.Metadata{
|
||||
DerivationIndex: 3,
|
||||
PublicKeyHash: "test-public-key-hash",
|
||||
}
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load metadata: %v", err)
|
||||
}
|
||||
|
||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
|
||||
metadata.DerivationIndex, loaded.DerivationIndex)
|
||||
}
|
||||
|
||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
|
||||
metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func testDifferentKeysForDifferentIndices(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Derive keys with different indices
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
||||
}
|
||||
|
||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
||||
}
|
||||
|
||||
// Compute public key hashes
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKey1 := identity1.Recipient().String()
|
||||
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Verify different indices produce different public keys
|
||||
if pubKey0 == pubKey1 {
|
||||
t.Errorf("Different derivation indices should produce different public keys")
|
||||
}
|
||||
|
||||
// But the hash of index 0's public key should be the same for the same
|
||||
// mnemonic. This is what we use as the identifier
|
||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
pubKey0Again := identity0Again.Recipient().String()
|
||||
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||
|
||||
if hash0 != hash0Again {
|
||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyHashConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use the same test mnemonic that the integration test uses
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Derive identity from index 0 multiple times
|
||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
@@ -258,8 +223,8 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
// Compute public key hashes
|
||||
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
||||
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
||||
hash1 := ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
||||
hash2 := ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
||||
|
||||
// Verify hashes are the same
|
||||
if hash1 != hash2 {
|
||||
@@ -272,15 +237,11 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSampleHashCalculation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test with the exact mnemonic from integration test if available. We
|
||||
// also test with a few different mnemonics to make sure they produce
|
||||
// different hashes
|
||||
// Test with the exact mnemonic from integration test if available
|
||||
// We'll also test with a few different mnemonics to make sure they produce different hashes
|
||||
mnemonics := []string{
|
||||
testMnemonic,
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||
//nolint:dupword // BIP39-style test mnemonic
|
||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||
}
|
||||
|
||||
@@ -290,29 +251,29 @@ func TestSampleHashCalculation(t *testing.T) {
|
||||
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
|
||||
t.Logf(" Recipient: %s", identity.Recipient().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowMismatch(t *testing.T) {
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||
|
||||
_, err := vault.CreateVault(fs, tempDir, "default")
|
||||
_, err := CreateVault(fs, tempDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||
}
|
||||
|
||||
// Load metadata for vault1
|
||||
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||
|
||||
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||
metadata1, err := LoadVaultMetadata(fs, vault1Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||
}
|
||||
@@ -320,10 +281,9 @@ func TestWorkflowMismatch(t *testing.T) {
|
||||
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||
|
||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (like work vault)
|
||||
t.Setenv("SB_SECRET_MNEMONIC", "")
|
||||
|
||||
_, err = vault.CreateVault(fs, tempDir, "work")
|
||||
_, err = CreateVault(fs, tempDir, "work")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||
}
|
||||
@@ -334,7 +294,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||
derivationIndex, err := GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||
}
|
||||
@@ -346,12 +306,10 @@ func TestWorkflowMismatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||
}
|
||||
|
||||
publicKeyHash := vault.ComputeDoubleSHA256(
|
||||
[]byte(identity0.Recipient().String()))
|
||||
publicKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
// Load existing metadata and update it (same as in VaultImport)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
existingMetadata, err := LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||
}
|
||||
@@ -360,13 +318,12 @@ func TestWorkflowMismatch(t *testing.T) {
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
||||
if err != nil {
|
||||
if err := SaveVaultMetadata(fs, vault2Dir, existingMetadata); err != nil {
|
||||
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load updated metadata for vault2
|
||||
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
metadata2, err := LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||
}
|
||||
@@ -380,59 +337,57 @@ func TestWorkflowMismatch(t *testing.T) {
|
||||
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||
} else {
|
||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
||||
metadata1.PublicKeyHash)
|
||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s", metadata1.PublicKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseEngineerHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This is the hash that the work vault is getting in the failing test
|
||||
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
||||
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
||||
|
||||
// Test mnemonic from integration test
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Calculate hash for test mnemonic
|
||||
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
|
||||
calculatedHash := vault.ComputeDoubleSHA256(
|
||||
[]byte(identity.Recipient().String()))
|
||||
calculatedHash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
||||
|
||||
if calculatedHash == correctHash {
|
||||
t.Logf("Test mnemonic produces the correct hash")
|
||||
t.Logf("✓ Test mnemonic produces the correct hash")
|
||||
} else {
|
||||
t.Errorf("Test mnemonic does not produce the correct hash")
|
||||
t.Errorf("✗ Test mnemonic does not produce the correct hash")
|
||||
}
|
||||
|
||||
if calculatedHash == wrongHash {
|
||||
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
|
||||
t.Logf("✗ Test mnemonic unexpectedly produces the wrong hash")
|
||||
}
|
||||
|
||||
// Try some other possibilities: maybe a string normalization issue?
|
||||
// Let's try some other possibilities - maybe there's a string normalization issue?
|
||||
variations := []string{
|
||||
testMnemonic,
|
||||
" " + testMnemonic + " ",
|
||||
testMnemonic + "\n",
|
||||
strings.TrimSpace(testMnemonic),
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n",
|
||||
strings.TrimSpace("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"),
|
||||
}
|
||||
|
||||
for i, variation := range variations {
|
||||
identity, err := agehd.DeriveIdentity(variation, 0)
|
||||
if err != nil {
|
||||
t.Logf("Variation %d failed: %v", i, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Variation %d hash: %s", i, hash)
|
||||
|
||||
if hash == wrongHash {
|
||||
t.Logf("Found variation that produces wrong hash: '%s'", variation)
|
||||
t.Logf("✗ Found variation that produces wrong hash: '%s'", variation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,15 +401,14 @@ func TestReverseEngineerHash(t *testing.T) {
|
||||
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
||||
|
||||
if hash == wrongHash {
|
||||
t.Logf("Empty mnemonic produces wrong hash!")
|
||||
t.Logf("✗ Empty mnemonic produces wrong hash!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package vault_test
|
||||
package vault
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||
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, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a legitimate secret so the vault is set up
|
||||
@@ -42,41 +42,42 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||
for _, name := range maliciousNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := vlt.GetSecretVersion(name, "")
|
||||
require.Error(t, err,
|
||||
"GetSecretVersion should reject malicious name: %s", name)
|
||||
require.Contains(t, err.Error(), "invalid secret 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.
|
||||
// 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, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid secret name")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid secret name")
|
||||
}
|
||||
|
||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||
// also validates names and rejects path traversal attempts.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||
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, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||
require.NoError(t, err)
|
||||
|
||||
maliciousNames := []string{
|
||||
@@ -88,8 +89,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||
for _, name := range maliciousNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := vlt.GetSecretObject(name)
|
||||
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||
require.Contains(t, err.Error(), "invalid secret name")
|
||||
assert.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||
assert.Contains(t, err.Error(), "invalid secret name")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,8 +21,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
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
|
||||
}
|
||||
@@ -33,15 +31,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
// Check if secrets directory exists
|
||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -49,14 +44,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
// List directories in secrets.d
|
||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||
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)
|
||||
}
|
||||
|
||||
var secrets []string
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Convert storage name back to secret name
|
||||
@@ -100,8 +93,10 @@ func isValidSecretName(name string) bool {
|
||||
}
|
||||
|
||||
// Check for path traversal via ".." components
|
||||
if slices.Contains(strings.Split(name, "/"), "..") {
|
||||
return false
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check the basic pattern
|
||||
@@ -113,7 +108,7 @@ func isValidSecretName(name string) bool {
|
||||
// AddSecret adds a secret to this vault
|
||||
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||
if value == nil {
|
||||
return ErrNilValueBuffer
|
||||
return fmt.Errorf("value buffer is nil")
|
||||
}
|
||||
|
||||
secret.DebugWith("Adding secret to vault",
|
||||
@@ -127,24 +122,17 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
||||
if !isValidSecretName(name) {
|
||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
|
||||
ErrInvalidSecretName, 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("Getting vault directory")
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
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
|
||||
}
|
||||
|
||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
@@ -156,30 +144,112 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
||||
slog.String("secret_dir", secretDir),
|
||||
)
|
||||
|
||||
// Check for an existing secret and prepare its directory
|
||||
exists, previousVersion, err := v.prepareSecretDir(name, secretDir, force)
|
||||
// Check if secret already exists
|
||||
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
secret.Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
// Handle existing secret case
|
||||
now := time.Now()
|
||||
var previousVersion *secret.Version
|
||||
|
||||
// Create the new version and save the encrypted value
|
||||
versionName, err := v.createAndSaveVersion(
|
||||
name, secretDir, value, previousVersion, &now, exists)
|
||||
if err != nil {
|
||||
return err
|
||||
if exists {
|
||||
if !force {
|
||||
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)
|
||||
}
|
||||
|
||||
// Get the current version to update its notAfter timestamp
|
||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err == nil && currentVersionName != "" {
|
||||
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
||||
// We'll need to load and update its metadata after we unlock the vault
|
||||
}
|
||||
} else {
|
||||
// Create secret directory for new secret
|
||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
||||
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
||||
|
||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||
}
|
||||
secret.Debug("Created secret directory successfully")
|
||||
}
|
||||
|
||||
// Update previous version's notAfter timestamp if it exists
|
||||
err = v.updatePreviousVersion(previousVersion, &now)
|
||||
// Generate new version name
|
||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||
|
||||
return fmt.Errorf("failed to generate version name: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||
|
||||
// Create new version
|
||||
newVersion := secret.NewVersion(v, name, versionName)
|
||||
|
||||
// Set version timestamps
|
||||
if previousVersion == nil {
|
||||
// First version: notBefore = epoch + 1 second
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
newVersion.Metadata.NotBefore = &epochPlusOne
|
||||
} else {
|
||||
// New version: notBefore = now
|
||||
newVersion.Metadata.NotBefore = &now
|
||||
|
||||
// We'll update the previous version's notAfter after we save the new version
|
||||
}
|
||||
|
||||
// Save the new version - pass the LockedBuffer directly
|
||||
if err := newVersion.Save(value); err != nil {
|
||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||
|
||||
// Clean up the secret directory if this was a new secret
|
||||
if !exists {
|
||||
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
|
||||
_ = v.fs.RemoveAll(secretDir)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to save version: %w", err)
|
||||
}
|
||||
|
||||
// Update previous version if it exists
|
||||
if previousVersion != nil {
|
||||
// Get long-term key to decrypt/encrypt metadata
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Load previous version metadata
|
||||
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = &now
|
||||
|
||||
// Re-save the metadata (we need to implement an update method)
|
||||
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set current symlink to new version
|
||||
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
|
||||
if err != nil {
|
||||
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
@@ -193,12 +263,9 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
||||
}
|
||||
|
||||
// updateVersionMetadata updates the metadata of an existing version
|
||||
func updateVersionMetadata(
|
||||
fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity) error {
|
||||
// Read the version's encrypted private key
|
||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
@@ -227,17 +294,14 @@ func updateVersionMetadata(
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer,
|
||||
versionIdentity.Recipient())
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted metadata
|
||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||
|
||||
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -254,8 +318,7 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||
return v.GetSecretVersion(name, "")
|
||||
}
|
||||
|
||||
// GetSecretVersion retrieves a specific version of a secret (empty version
|
||||
// means current)
|
||||
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
|
||||
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
secret.DebugWith("Getting secret version from vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
@@ -263,17 +326,69 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
slog.String("version", version),
|
||||
)
|
||||
|
||||
// Validate the name and resolve the version to fetch
|
||||
version, err := v.resolveSecretVersion(name, 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
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
// Determine which version to get
|
||||
if version == "" {
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
version = currentVersion
|
||||
secret.Debug("Using current version", "version", version, "secret_name", name)
|
||||
}
|
||||
|
||||
// Create version object
|
||||
secretVersion := secret.NewVersion(v, name, version)
|
||||
|
||||
secret.Debug("Version exists, proceeding with vault unlock and decryption",
|
||||
"version", version, "secret_name", name)
|
||||
// Check if version exists
|
||||
versionPath := filepath.Join(secretDir, "versions", version)
|
||||
exists, err = afero.DirExists(v.fs, versionPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
||||
}
|
||||
|
||||
secret.Debug("Version exists, proceeding with vault unlock and decryption", "version", version, "secret_name", name)
|
||||
|
||||
// Unlock the vault (get long-term key in memory)
|
||||
longTermIdentity, err := v.UnlockVault()
|
||||
@@ -291,13 +406,10 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
)
|
||||
|
||||
// Get the version's value
|
||||
secret.Debug("About to call secretVersion.GetValue",
|
||||
"version", version, "secret_name", name)
|
||||
|
||||
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
|
||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||
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)
|
||||
}
|
||||
@@ -330,8 +442,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
|
||||
// If vault is already unlocked, return the cached key
|
||||
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
|
||||
}
|
||||
@@ -339,8 +450,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
// Get or derive the long-term key (but don't store it yet)
|
||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
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)
|
||||
}
|
||||
@@ -359,7 +469,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
if !isValidSecretName(name) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrInvalidSecretName, name)
|
||||
return nil, fmt.Errorf("invalid secret name: %s", name)
|
||||
}
|
||||
|
||||
// First check if the secret exists by checking for the metadata file
|
||||
@@ -377,17 +487,15 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
// Create a Secret object
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
|
||||
// Load the metadata from disk
|
||||
err = secretObj.LoadMetadata()
|
||||
if err != nil {
|
||||
if err := secretObj.LoadMetadata(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -418,8 +526,7 @@ func (v *Vault) CopySecretVersion(
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Load source metadata
|
||||
err = srcVersion.LoadMetadata(srcIdentity)
|
||||
if err != nil {
|
||||
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
|
||||
return fmt.Errorf("failed to load source metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -430,8 +537,7 @@ func (v *Vault) CopySecretVersion(
|
||||
destVersion.Metadata = srcVersion.Metadata
|
||||
|
||||
// Save the version (encrypts to this vault's LT key)
|
||||
err = destVersion.Save(valueBuffer)
|
||||
if err != nil {
|
||||
if err := destVersion.Save(valueBuffer); err != nil {
|
||||
return fmt.Errorf("failed to save destination version: %w", err)
|
||||
}
|
||||
|
||||
@@ -465,13 +571,26 @@ func (v *Vault) CopySecretAllVersions(
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if destination secret already exists and clear it if forced
|
||||
// Check if destination secret already exists
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
err = v.prepareCopyDestination(destSecretDir, destSecretName, force)
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
@@ -496,7 +615,7 @@ func (v *Vault) CopySecretAllVersions(
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return fmt.Errorf("source secret '%s' %w", srcSecretName, ErrNoVersions)
|
||||
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
|
||||
}
|
||||
|
||||
// Get current version name
|
||||
@@ -506,16 +625,27 @@ func (v *Vault) CopySecretAllVersions(
|
||||
}
|
||||
|
||||
// Create destination secret directory
|
||||
err = v.fs.MkdirAll(destSecretDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
|
||||
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy each version and set the current pointer, rolling back on error
|
||||
err = v.copyVersionsWithRollback(srcVault, srcIdentity,
|
||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||
if err != nil {
|
||||
return 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",
|
||||
@@ -526,292 +656,3 @@ func (v *Vault) CopySecretAllVersions(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareSecretDir checks for an existing secret directory and prepares it
|
||||
// for a new version. It returns whether the secret already existed and the
|
||||
// current version to be superseded, if any.
|
||||
func (v *Vault) prepareSecretDir(
|
||||
name, secretDir string, force bool,
|
||||
) (bool, *secret.Version, error) {
|
||||
// Check if secret already exists
|
||||
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists",
|
||||
"error", err, "secret_dir", secretDir)
|
||||
|
||||
return false, nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
if !exists {
|
||||
// Create secret directory for new secret
|
||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||
|
||||
err = v.fs.MkdirAll(secretDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create secret directory",
|
||||
"error", err, "secret_dir", secretDir)
|
||||
|
||||
return false, nil, fmt.Errorf("failed to create secret directory: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Created secret directory successfully")
|
||||
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
if !force {
|
||||
secret.Debug("Secret already exists and force not specified",
|
||||
"secret_name", name, "secret_dir", secretDir)
|
||||
|
||||
return true, nil, fmt.Errorf(
|
||||
"secret %s %w (use --force to overwrite)",
|
||||
name, ErrSecretExists,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the current version to update its notAfter timestamp
|
||||
var previousVersion *secret.Version
|
||||
|
||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err == nil && currentVersionName != "" {
|
||||
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
||||
// We'll need to load and update its metadata after we unlock the vault
|
||||
}
|
||||
|
||||
return true, previousVersion, nil
|
||||
}
|
||||
|
||||
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||
// superseded. It is a no-op when previousVersion is nil.
|
||||
func (v *Vault) updatePreviousVersion(
|
||||
previousVersion *secret.Version, now *time.Time,
|
||||
) error {
|
||||
if previousVersion == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get long-term key to decrypt/encrypt metadata
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Load previous version metadata
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = now
|
||||
|
||||
// Re-save the metadata (we need to implement an update method)
|
||||
err = updateVersionMetadata(v.fs, previousVersion, ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSecretVersion validates the secret name, verifies the secret and
|
||||
// version exist, and resolves an empty version to the current one.
|
||||
func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
|
||||
// Validate secret name to prevent path traversal
|
||||
if !isValidSecretName(name) {
|
||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
|
||||
ErrInvalidSecretName, name,
|
||||
)
|
||||
}
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
|
||||
return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Determine which version to get
|
||||
if version == "" {
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
version = currentVersion
|
||||
|
||||
secret.Debug("Using current version", "version", version, "secret_name", name)
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
versionPath := filepath.Join(secretDir, "versions", version)
|
||||
|
||||
exists, err = afero.DirExists(v.fs, versionPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||
|
||||
return "", fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"version %s %w %s",
|
||||
version, ErrVersionNotFound, name,
|
||||
)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// createAndSaveVersion generates a new version name, sets the version
|
||||
// timestamps, and saves the encrypted value. When saving fails for a newly
|
||||
// created secret, the secret directory is removed again.
|
||||
func (v *Vault) createAndSaveVersion(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
previousVersion *secret.Version, now *time.Time, exists bool,
|
||||
) (string, error) {
|
||||
// Generate new version name
|
||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf("failed to generate version name: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||
|
||||
// Create new version
|
||||
newVersion := secret.NewVersion(v, name, versionName)
|
||||
|
||||
// Set version timestamps
|
||||
if previousVersion == nil {
|
||||
// First version: notBefore = epoch + 1 second
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
newVersion.Metadata.NotBefore = &epochPlusOne
|
||||
} else {
|
||||
// New version: notBefore = now
|
||||
newVersion.Metadata.NotBefore = now
|
||||
|
||||
// We'll update the previous version's notAfter after we save the
|
||||
// new version
|
||||
}
|
||||
|
||||
// Save the new version - pass the LockedBuffer directly
|
||||
err = newVersion.Save(value)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||
|
||||
// Clean up the secret directory if this was a new secret
|
||||
if !exists {
|
||||
secret.Debug("Cleaning up secret directory due to save failure",
|
||||
"secret_dir", secretDir)
|
||||
|
||||
_ = v.fs.RemoveAll(secretDir)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to save version: %w", err)
|
||||
}
|
||||
|
||||
return versionName, nil
|
||||
}
|
||||
|
||||
// copyVersionsWithRollback copies each version of the source secret into the
|
||||
// destination directory and sets the current version pointer, removing the
|
||||
// partial copy when any step fails.
|
||||
func (v *Vault) copyVersionsWithRollback(
|
||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||
srcSecretName, destSecretName, destSecretDir string,
|
||||
versions []string, currentVersion string,
|
||||
) error {
|
||||
// Copy each version
|
||||
for _, versionName := range versions {
|
||||
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
||||
|
||||
err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName)
|
||||
if 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
|
||||
err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion)
|
||||
if err != nil {
|
||||
_ = v.fs.RemoveAll(destSecretDir)
|
||||
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareCopyDestination ensures the destination secret directory can be
|
||||
// created, removing an existing secret when force is set.
|
||||
func (v *Vault) prepareCopyDestination(
|
||||
destSecretDir, destSecretName string, force bool,
|
||||
) error {
|
||||
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' %w in vault '%s' (use --force to overwrite)",
|
||||
destSecretName, ErrSecretExists, v.Name,
|
||||
)
|
||||
}
|
||||
|
||||
if exists && force {
|
||||
// Remove existing secret
|
||||
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
||||
|
||||
err = v.fs.RemoveAll(destSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
//nolint:testpackage // white-box test of unexported isValidSecretName
|
||||
package vault
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
@@ -36,8 +33,6 @@ func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := isValidSecretName(tt.name)
|
||||
if result != tt.valid {
|
||||
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
//
|
||||
// Integration tests for vault-level version operations:
|
||||
//
|
||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
||||
// version structure
|
||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
||||
// force flag
|
||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
||||
// version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||
// across versions
|
||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper version structure
|
||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with force flag
|
||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter) across versions
|
||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||
//
|
||||
@@ -19,7 +15,6 @@
|
||||
// - Promotion doesn't modify timestamps
|
||||
// - Metadata remains encrypted and intact
|
||||
|
||||
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
||||
package vault
|
||||
|
||||
import (
|
||||
@@ -35,61 +30,33 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testMnemonic is the mnemonic used to derive the vault long-term key.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon about"
|
||||
|
||||
// envTestMnemonic is the (deliberately different) mnemonic placed in the
|
||||
// environment; the vault is unlocked manually with the derived key in
|
||||
// createTestVaultWithKey.
|
||||
//
|
||||
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
|
||||
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon about"
|
||||
|
||||
// Shared fixtures for white-box tests in this package.
|
||||
const (
|
||||
testStateDir = "/test/state"
|
||||
testSecretPath = "test/secret"
|
||||
)
|
||||
|
||||
// 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,
|
||||
) {
|
||||
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 named "test" with its long-term key set
|
||||
// up and unlocked
|
||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||
t.Helper()
|
||||
|
||||
// Helper function to create a vault with long-term key set up
|
||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vault, err := CreateVault(fs, testStateDir, "test")
|
||||
vault, err := CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault with the derived key
|
||||
@@ -98,19 +65,20 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||
return vault
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("initial-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
||||
addTestSecretToVault(t, vault, secretName, secretValue, false)
|
||||
|
||||
// Check that version directory was created
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -129,31 +97,32 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
|
||||
// Get the secret value
|
||||
retrievedValue, err := vault.GetSecret(testSecretPath)
|
||||
retrievedValue, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedValue, retrievedValue)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add first version
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||
|
||||
// Try to add again without force - should fail
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||
defer failBuffer.Destroy()
|
||||
|
||||
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
||||
require.Error(t, err)
|
||||
err := vault.AddSecret(secretName, failBuffer, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
|
||||
// Add with force - should create new version
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
|
||||
// Check that we have two versions
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -163,25 +132,27 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
assert.Len(t, entries, 2)
|
||||
|
||||
// Current value should be version-2
|
||||
value, err := vault.GetSecret(testSecretPath)
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVaultGetSecretVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add multiple versions
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||
|
||||
// Small delay to ensure different version names
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
|
||||
// Get versions list
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -192,62 +163,58 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
||||
|
||||
// Get specific version (first one)
|
||||
firstVersion := versions[1] // Last in list is first created
|
||||
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
||||
value, err := vault.GetSecretVersion(secretName, firstVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
|
||||
// Get specific version (second one)
|
||||
secondVersion := versions[0] // First in list is most recent
|
||||
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
|
||||
value, err = vault.GetSecretVersion(secretName, secondVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
|
||||
// Get current (empty version)
|
||||
value, err = vault.GetSecretVersion(testSecretPath, "")
|
||||
value, err = vault.GetSecretVersion(secretName, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVaultVersionTimestamps(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add first version
|
||||
beforeFirst := time.Now()
|
||||
|
||||
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||
defer v1Buffer.Destroy()
|
||||
|
||||
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
||||
err = vault.AddSecret(secretName, v1Buffer, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterFirst := time.Now()
|
||||
|
||||
// Get first version metadata
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
firstVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check first version timestamps
|
||||
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
||||
@@ -255,11 +222,8 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
|
||||
// Add second version
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
beforeSecond := time.Now()
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
afterSecond := time.Now()
|
||||
|
||||
// Get updated versions
|
||||
@@ -268,59 +232,56 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Reload first version metadata (should have notAfter now)
|
||||
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
|
||||
firstVersion = secret.NewVersion(vault, secretName, versions[1])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||
|
||||
// Check second version timestamps
|
||||
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.True(t,
|
||||
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
||||
assert.True(t, secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t, secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Add a secret
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
||||
addTestSecretToVault(t, vault, "test/secret", []byte("value"), false)
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
||||
require.Error(t, err)
|
||||
_, err := vault.GetSecretVersion("test/secret", "20991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||
func TestUpdateVersionMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a version manually to test updateVersionMetadata
|
||||
secretName := "test/secret"
|
||||
versionName := "20231215.001"
|
||||
version := secret.NewVersion(vault, testSecretPath, versionName)
|
||||
version := secret.NewVersion(vault, secretName, versionName)
|
||||
|
||||
// Set initial metadata
|
||||
now := time.Now()
|
||||
@@ -331,7 +292,6 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
||||
// Save version
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = version.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -341,7 +301,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load and verify
|
||||
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
||||
version2 := secret.NewVersion(vault, secretName, versionName)
|
||||
err = version2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -14,22 +14,13 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Unlocker metadata type strings.
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypeSecureEnclave = "secure-enclave"
|
||||
)
|
||||
|
||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||
//
|
||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
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
|
||||
}
|
||||
@@ -39,8 +30,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
// Check if the symlink exists
|
||||
_, err = v.fs.Stat(currentUnlockerPath)
|
||||
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)
|
||||
}
|
||||
@@ -57,37 +47,49 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
)
|
||||
|
||||
// Read unlocker metadata
|
||||
metadata, err := v.readUnlockerMetadata(unlockerDir)
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
secret.Debug("Reading unlocker metadata", "path", metadataPath)
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Parsed unlocker metadata",
|
||||
slog.String("unlocker_type", metadata.Type),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Any("flags", metadata.Flags),
|
||||
)
|
||||
|
||||
// Create unlocker instance using direct constructors with filesystem
|
||||
var unlocker secret.Unlocker
|
||||
// Use metadata directly as it's already the correct type
|
||||
switch metadata.Type {
|
||||
case unlockerTypePassphrase:
|
||||
secret.Debug("Creating passphrase unlocker instance",
|
||||
"unlocker_type", metadata.Type)
|
||||
|
||||
case "passphrase":
|
||||
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
case "pgp":
|
||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||
case "keychain":
|
||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||
case unlockerTypeSecureEnclave:
|
||||
secret.Debug("Creating secure enclave unlocker instance",
|
||||
"unlocker_type", metadata.Type)
|
||||
|
||||
case "secure-enclave":
|
||||
secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type)
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
||||
default:
|
||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedUnlockerType, metadata.Type)
|
||||
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully created unlocker instance",
|
||||
@@ -99,16 +101,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
||||
// unlocker directory path
|
||||
// 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)
|
||||
secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath)
|
||||
|
||||
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
||||
}
|
||||
@@ -125,13 +125,8 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||
// instance and its directory path
|
||||
//
|
||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||
func (v *Vault) findUnlockerByID(
|
||||
unlockersDir, unlockerID string,
|
||||
) (secret.Unlocker, string, error) {
|
||||
// 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)
|
||||
@@ -144,14 +139,10 @@ func (v *Vault) findUnlockerByID(
|
||||
|
||||
// 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)
|
||||
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
|
||||
@@ -159,31 +150,26 @@ func (v *Vault) findUnlockerByID(
|
||||
|
||||
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)
|
||||
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf(
|
||||
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
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 unlockerTypePassphrase:
|
||||
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 unlockerTypeSecureEnclave:
|
||||
case "secure-enclave":
|
||||
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
default:
|
||||
continue
|
||||
@@ -212,7 +198,6 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []UnlockerMetadata{}, nil
|
||||
}
|
||||
@@ -224,39 +209,28 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||
}
|
||||
|
||||
var unlockers []UnlockerMetadata
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockersDir, file.Name(),
|
||||
"unlocker-metadata.json")
|
||||
|
||||
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)
|
||||
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Warn("Skipping unlocker directory with missing metadata file",
|
||||
"directory", file.Name())
|
||||
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
|
||||
|
||||
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)
|
||||
return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
unlockers = append(unlockers, metadata)
|
||||
@@ -283,7 +257,7 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
||||
}
|
||||
|
||||
// Use the unlocker's Remove method
|
||||
@@ -307,21 +281,17 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
}
|
||||
|
||||
if targetUnlockerDir == "" {
|
||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
||||
}
|
||||
|
||||
// Create/update current-unlocker file with just the unlocker name
|
||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||
|
||||
// Remove existing file if it exists
|
||||
exists, err := afero.Exists(v.fs, currentUnlockerPath)
|
||||
if err != nil {
|
||||
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
||||
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
err = v.fs.Remove(currentUnlockerPath)
|
||||
if err != nil {
|
||||
} else if exists {
|
||||
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
||||
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -331,10 +301,7 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
|
||||
// Write just the unlocker name to the file
|
||||
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||
|
||||
err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName),
|
||||
secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||
}
|
||||
|
||||
@@ -343,19 +310,15 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
// The passphrase must be provided as a LockedBuffer for security
|
||||
func (v *Vault) CreatePassphraseUnlocker(
|
||||
passphrase *memguard.LockedBuffer,
|
||||
) (*secret.PassphraseUnlocker, error) {
|
||||
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
|
||||
|
||||
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
||||
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -365,15 +328,32 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Write the unlocker keypair (public and passphrase-encrypted private)
|
||||
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
|
||||
// Write public key
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
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)
|
||||
}
|
||||
|
||||
// Encrypt private key with passphrase
|
||||
privKeyStr := unlockerIdentity.String()
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
||||
defer privKeyBuffer.Destroy()
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted private key
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
metadata := UnlockerMetadata{
|
||||
Type: unlockerTypePassphrase,
|
||||
Type: "passphrase",
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{},
|
||||
}
|
||||
@@ -385,9 +365,7 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -401,16 +379,13 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
|
||||
unlockerIdentity.Recipient())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -418,80 +393,9 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
|
||||
// Select this unlocker as current
|
||||
err = v.SelectUnlocker(unlocker.GetID())
|
||||
if err != nil {
|
||||
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||
}
|
||||
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
||||
// the given unlocker directory.
|
||||
func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, error) {
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
secret.Debug("Reading unlocker metadata", "path", metadataPath)
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return metadata, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return metadata, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Parsed unlocker metadata",
|
||||
slog.String("unlocker_type", metadata.Type),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Any("flags", metadata.Flags),
|
||||
)
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// writeUnlockerKeypair writes the unlocker's public key and its
|
||||
// passphrase-encrypted private key into the unlocker directory.
|
||||
func (v *Vault) writeUnlockerKeypair(
|
||||
unlockerDir string,
|
||||
unlockerIdentity *age.X25519Identity,
|
||||
passphrase *memguard.LockedBuffer,
|
||||
) error {
|
||||
// Write public key
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
|
||||
err := afero.WriteFile(v.fs, pubKeyPath,
|
||||
[]byte(unlockerIdentity.Recipient().String()),
|
||||
secret.FilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt private key with passphrase
|
||||
privKeyStr := unlockerIdentity.String()
|
||||
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted private key
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
|
||||
err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,14 +23,12 @@ type Vault struct {
|
||||
// NewVault creates a new Vault instance
|
||||
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
||||
secret.Debug("Creating NewVault instance")
|
||||
|
||||
v := &Vault{
|
||||
Name: name,
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
longTermKey: nil,
|
||||
}
|
||||
|
||||
secret.Debug("Created NewVault instance successfully")
|
||||
|
||||
return v
|
||||
@@ -56,8 +54,7 @@ func (v *Vault) ClearLongTermKey() {
|
||||
v.longTermKey = nil
|
||||
}
|
||||
|
||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||
// from available sources
|
||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
|
||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
// If we have it in memory, return it
|
||||
if !v.Locked() {
|
||||
@@ -68,12 +65,55 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
|
||||
// Try to derive from environment mnemonic first
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
|
||||
secret.Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
|
||||
|
||||
// Load vault metadata to get the derivation index
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Verify that the derived key matches the stored public key hash
|
||||
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
if derivedPubKeyHash != metadata.PublicKeyHash {
|
||||
secret.Debug("Derived public key hash does not match stored hash",
|
||||
"vault_name", v.Name,
|
||||
"derived_hash", derivedPubKeyHash,
|
||||
"stored_hash", metadata.PublicKeyHash,
|
||||
"derivation_index", metadata.DerivationIndex)
|
||||
|
||||
return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Cache the derived key by unlocking the vault
|
||||
v.Unlock(ltIdentity)
|
||||
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic", "vault_name", v.Name)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// No mnemonic available, try to use current unlocker
|
||||
secret.Debug("No mnemonic available, using current unlocker to unlock vault",
|
||||
"vault_name", v.Name)
|
||||
secret.Debug("No mnemonic available, using current unlocker to unlock vault", "vault_name", v.Name)
|
||||
|
||||
// Get current unlocker
|
||||
unlocker, err := v.GetCurrentUnlocker()
|
||||
@@ -111,130 +151,10 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetDirectory returns the vault's directory path
|
||||
func (v *Vault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
||||
}
|
||||
|
||||
// GetName returns the vault's name (for VaultInterface compatibility)
|
||||
func (v *Vault) GetName() string {
|
||||
return v.Name
|
||||
}
|
||||
|
||||
// GetFilesystem returns the vault's filesystem (for VaultInterface
|
||||
// compatibility)
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the interface required by VaultInterface
|
||||
func (v *Vault) GetFilesystem() afero.Fs {
|
||||
return v.fs
|
||||
}
|
||||
|
||||
// NumSecrets returns the number of secrets in the vault
|
||||
func (v *Vault) NumSecrets() (int, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||
if !exists {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
// Count only directories that 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
|
||||
}
|
||||
|
||||
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
||||
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
||||
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
||||
envMnemonic string,
|
||||
) (*age.X25519Identity, error) {
|
||||
secret.Debug("Using mnemonic from environment for long-term key derivation",
|
||||
"vault_name", v.Name)
|
||||
|
||||
// Load vault metadata to get the derivation index
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Verify that the derived key matches the stored public key hash
|
||||
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
if derivedPubKeyHash != metadata.PublicKeyHash {
|
||||
secret.Debug("Derived public key hash does not match stored hash",
|
||||
"vault_name", v.Name,
|
||||
"derived_hash", derivedPubKeyHash,
|
||||
"stored_hash", metadata.PublicKeyHash,
|
||||
"derivation_index", metadata.DerivationIndex)
|
||||
|
||||
return nil, ErrMnemonicMismatch
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Cache the derived key by unlocking the vault
|
||||
v.Unlock(ltIdentity)
|
||||
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic",
|
||||
"vault_name", v.Name)
|
||||
|
||||
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() == unlockerTypeSecureEnclave {
|
||||
// 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()
|
||||
@@ -258,8 +178,7 @@ func (v *Vault) unlockLongTermKey(
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
|
||||
encryptedLtPrivKey, unlockerIdentity)
|
||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
@@ -272,3 +191,59 @@ func (v *Vault) unlockLongTermKey(
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetDirectory returns the vault's directory path
|
||||
func (v *Vault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
||||
}
|
||||
|
||||
// GetName returns the vault's name (for VaultInterface compatibility)
|
||||
func (v *Vault) GetName() string {
|
||||
return v.Name
|
||||
}
|
||||
|
||||
// GetFilesystem returns the vault's filesystem (for VaultInterface compatibility)
|
||||
func (v *Vault) GetFilesystem() afero.Fs {
|
||||
return v.fs
|
||||
}
|
||||
|
||||
// NumSecrets returns the number of secrets in the vault
|
||||
func (v *Vault) NumSecrets() (int, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||
if !exists {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
// Count only directories that 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
|
||||
}
|
||||
|
||||
@@ -13,34 +13,32 @@ import (
|
||||
)
|
||||
|
||||
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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(testStateDir, "vaults.d", "broken")
|
||||
// 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(testStateDir, "currentvault")
|
||||
require.NoError(t,
|
||||
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||
|
||||
// Create vault instance
|
||||
vlt := vault.NewVault(fs, testStateDir, "broken")
|
||||
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(testSecretName, value, false)
|
||||
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", testSecretName)
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
exists, _ := afero.DirExists(fs, secretDir)
|
||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||
|
||||
@@ -49,45 +47,41 @@ func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
||||
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")
|
||||
assert.Empty(t, entries, "secrets.d directory should be empty after failed AddSecret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create a vault directory with public key
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", "test")
|
||||
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))
|
||||
require.NoError(t, afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"), secret.FilePerms))
|
||||
|
||||
// Create currentvault symlink
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
require.NoError(t,
|
||||
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||
|
||||
// Create vault instance
|
||||
vlt := vault.NewVault(fs, testStateDir, "test")
|
||||
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(testSecretName, value, false)
|
||||
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", testSecretName)
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
exists, _ := afero.DirExists(fs, secretDir)
|
||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||
}
|
||||
|
||||
@@ -1,301 +1,268 @@
|
||||
package vault_test
|
||||
package vault
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// testMnemonic is the shared BIP39 test mnemonic for tests in this package.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon about"
|
||||
|
||||
// Shared fixtures for tests in this package.
|
||||
const (
|
||||
testStateDir = "/test/state"
|
||||
testVaultName = "test-vault"
|
||||
testSecretName = "test-secret"
|
||||
testPassphrase = "test-passphrase"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
|
||||
func TestVaultOperations(t *testing.T) {
|
||||
// Test environment will be cleaned up automatically by t.Setenv
|
||||
|
||||
// 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, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Test vault creation
|
||||
t.Run("CreateVault", func(t *testing.T) {
|
||||
testCreateVault(t, fs)
|
||||
})
|
||||
|
||||
t.Run("ListVaults", func(t *testing.T) {
|
||||
testListVaults(t, fs)
|
||||
})
|
||||
|
||||
t.Run("SelectVault", func(t *testing.T) {
|
||||
testSelectVault(t, fs)
|
||||
})
|
||||
|
||||
t.Run("SecretOperations", func(t *testing.T) {
|
||||
testSecretOperations(t, fs)
|
||||
})
|
||||
|
||||
t.Run("NumSecrets", func(t *testing.T) {
|
||||
testNumSecrets(t, fs)
|
||||
})
|
||||
|
||||
t.Run("UnlockerOperations", func(t *testing.T) {
|
||||
testUnlockerOperations(t, fs)
|
||||
})
|
||||
}
|
||||
|
||||
func testCreateVault(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
if vlt.GetName() != testVaultName {
|
||||
t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
|
||||
}
|
||||
|
||||
// Check vault directory exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check vault directory: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Vault directory should exist")
|
||||
}
|
||||
}
|
||||
|
||||
func testListVaults(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(vaults, testVaultName) {
|
||||
t.Errorf("Expected to find '%s' in vault list", testVaultName)
|
||||
}
|
||||
}
|
||||
|
||||
func testSelectVault(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
err := vault.SelectVault(fs, testStateDir, testVaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current vault
|
||||
currentVault, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != testVaultName {
|
||||
t.Errorf("Expected current vault '%s', got '%s'",
|
||||
testVaultName, currentVault.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
func testSecretOperations(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// First, derive the long-term key from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the public key from the derived identity
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write the correct public key to the pub.age file
|
||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault with the derived identity
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Now add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("test-secret-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret: %v", err)
|
||||
}
|
||||
|
||||
// List secrets
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(secrets, secretName) {
|
||||
t.Errorf("Expected to find secret '%s' in list", secretName)
|
||||
}
|
||||
|
||||
// Get secret value
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
t.Errorf("Expected secret value '%s', got '%s'",
|
||||
string(expectedValue), string(retrievedValue))
|
||||
}
|
||||
}
|
||||
|
||||
func testNumSecrets(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// Test vault unlocking (should happen automatically via mnemonic)
|
||||
if vlt.Locked() {
|
||||
_, err := vlt.UnlockVault()
|
||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unlock vault: %v", err)
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a passphrase unlocker
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||
}
|
||||
|
||||
// List unlockers
|
||||
unlockers, err := vlt.ListUnlockers()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list unlockers: %v", err)
|
||||
}
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
t.Errorf("Expected at least one unlocker")
|
||||
}
|
||||
|
||||
// Check key type
|
||||
keyFound := false
|
||||
|
||||
for _, key := range unlockers {
|
||||
if key.Type == "passphrase" {
|
||||
keyFound = true
|
||||
|
||||
break
|
||||
if vlt.GetName() != "test-vault" {
|
||||
t.Errorf("Expected vault name 'test-vault', got '%s'", vlt.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
if !keyFound {
|
||||
t.Errorf("Expected to find passphrase unlocker")
|
||||
}
|
||||
// Check vault directory exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Test selecting unlocker
|
||||
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select unlocker: %v", err)
|
||||
}
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current unlocker
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current unlocker: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Vault directory should exist")
|
||||
}
|
||||
})
|
||||
|
||||
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
||||
t.Errorf("Expected current unlocker ID '%s', got '%s'",
|
||||
passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
||||
}
|
||||
// Test vault listing
|
||||
t.Run("ListVaults", func(t *testing.T) {
|
||||
vaults, err := ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, vault := range vaults {
|
||||
if vault == "test-vault" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected to find 'test-vault' in vault list")
|
||||
}
|
||||
})
|
||||
|
||||
// Test vault selection
|
||||
t.Run("SelectVault", func(t *testing.T) {
|
||||
err := SelectVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current vault
|
||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != "test-vault" {
|
||||
t.Errorf("Expected current vault 'test-vault', got '%s'", currentVault.GetName())
|
||||
}
|
||||
})
|
||||
|
||||
// Test secret operations
|
||||
t.Run("SecretOperations", func(t *testing.T) {
|
||||
vlt, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// First, derive the long-term key from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the public key from the derived identity
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write the correct public key to the pub.age file
|
||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault with the derived identity
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Now add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("test-secret-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret: %v", err)
|
||||
}
|
||||
|
||||
// List secrets
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, secret := range secrets {
|
||||
if secret == secretName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected to find secret '%s' in list", secretName)
|
||||
}
|
||||
|
||||
// Get secret value
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// Test unlocker operations
|
||||
t.Run("UnlockerOperations", func(t *testing.T) {
|
||||
vlt, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// Test vault unlocking (should happen automatically via mnemonic)
|
||||
if vlt.Locked() {
|
||||
_, err := vlt.UnlockVault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unlock vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a passphrase unlocker
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphraseBuffer.Destroy()
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||
}
|
||||
|
||||
// List unlockers
|
||||
unlockers, err := vlt.ListUnlockers()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list unlockers: %v", err)
|
||||
}
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
t.Errorf("Expected at least one unlocker")
|
||||
}
|
||||
|
||||
// Check key type
|
||||
keyFound := false
|
||||
for _, key := range unlockers {
|
||||
if key.Type == "passphrase" {
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !keyFound {
|
||||
t.Errorf("Expected to find passphrase unlocker")
|
||||
}
|
||||
|
||||
// Test selecting unlocker
|
||||
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select unlocker: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current unlocker
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current unlocker: %v", err)
|
||||
}
|
||||
|
||||
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
||||
t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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, testPassphrase)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
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(testPassphrase))
|
||||
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)
|
||||
@@ -306,9 +273,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||
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)
|
||||
@@ -317,8 +282,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||
// 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)
|
||||
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
|
||||
}
|
||||
|
||||
// Should still have the valid passphrase unlocker
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
package agehd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -29,10 +28,6 @@ const (
|
||||
x25519KeySize = 32 // 256-bit key size for X25519
|
||||
)
|
||||
|
||||
// errInvalidScalarSize is returned when the entropy is not exactly 32
|
||||
// bytes long.
|
||||
var errInvalidScalarSize = errors.New("need 32-byte scalar")
|
||||
|
||||
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
||||
func clamp(k []byte) {
|
||||
k[0] &= 248
|
||||
@@ -44,7 +39,7 @@ func clamp(k []byte) {
|
||||
// *age.X25519Identity by round-tripping through Bech32.
|
||||
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||
if len(ent) != x25519KeySize {
|
||||
return nil, fmt.Errorf("%w, got %d", errInvalidScalarSize, len(ent))
|
||||
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
||||
}
|
||||
|
||||
// Make a copy to avoid modifying the original
|
||||
@@ -56,12 +51,10 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||
bech32BitSize8 = 8 // Standard 8-bit encoding
|
||||
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
||||
)
|
||||
|
||||
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
||||
}
|
||||
|
||||
s, err := bech32.Encode(hrp, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bech32 encode: %w", err)
|
||||
@@ -94,7 +87,6 @@ func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||
drng := bip85.NewBIP85DRNG(entropy)
|
||||
key := make([]byte, x25519KeySize)
|
||||
|
||||
_, err = drng.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||
@@ -124,7 +116,6 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
|
||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||
drng := bip85.NewBIP85DRNG(entropy)
|
||||
key := make([]byte, x25519KeySize)
|
||||
|
||||
_, err = drng.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -24,10 +23,10 @@ import (
|
||||
|
||||
const (
|
||||
// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
|
||||
BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // BIP85 spec naming
|
||||
BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
|
||||
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // BIP85 spec naming
|
||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
|
||||
// AppBIP39 is the application number for BIP39 mnemonics
|
||||
AppBIP39 = 39
|
||||
@@ -35,50 +34,18 @@ const (
|
||||
AppHDWIF = 2
|
||||
// AppXPRV is the application number for extended private key
|
||||
AppXPRV = 32
|
||||
APP_HEX = 128169 //nolint:revive // BIP85 spec naming
|
||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // BIP85 spec naming
|
||||
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
AppPWD85 = 707785 // Base85 passwords
|
||||
APP_RSA = 828365 //nolint:revive // BIP85 spec naming
|
||||
)
|
||||
|
||||
// Sentinel errors for BIP85 derivation.
|
||||
var (
|
||||
// ErrNotPrivateKey is returned when the supplied master key is not a
|
||||
// private key.
|
||||
ErrNotPrivateKey = errors.New("master key must be a private key")
|
||||
// ErrInvalidPathComponent is returned when a derivation path component
|
||||
// cannot be parsed.
|
||||
ErrInvalidPathComponent = errors.New("invalid path component")
|
||||
// ErrInvalidWordCount is returned for unsupported BIP39 word counts.
|
||||
ErrInvalidWordCount = errors.New("invalid BIP39 word count")
|
||||
// ErrInvalidNumBytes is returned when numBytes is out of range.
|
||||
ErrInvalidNumBytes = errors.New("numBytes must be between 16 and 64")
|
||||
// ErrInvalidBase64PwdLen is returned when the Base64 password length
|
||||
// is out of range.
|
||||
ErrInvalidBase64PwdLen = errors.New("pwdLen must be between 20 and 86")
|
||||
// ErrInvalidBase85PwdLen is returned when the Base85 password length
|
||||
// is out of range.
|
||||
ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80")
|
||||
// ErrPasswordTooShort is returned when the derived material is
|
||||
// shorter than the requested password length. It carries only the
|
||||
// middle of the message, which the caller composes as
|
||||
// "derived password length <n> is shorter than requested length <m>",
|
||||
// so the emitted text is unchanged.
|
||||
ErrPasswordTooShort = errors.New("is shorter than requested length")
|
||||
// ErrEncodedTooShort is returned when the encoded material is shorter
|
||||
// than the requested password length. Composed as
|
||||
// "encoded length <n> is less than requested length <m>".
|
||||
ErrEncodedTooShort = errors.New("is less than requested length")
|
||||
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
)
|
||||
|
||||
// Version bytes for extended keys
|
||||
//
|
||||
//nolint:gochecknoglobals // standard BIP32 version constants
|
||||
var (
|
||||
// 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 = []byte{0x04, 0x35, 0x83, 0x94}
|
||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||
)
|
||||
|
||||
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
||||
@@ -104,7 +71,7 @@ func NewBIP85DRNG(entropy []byte) *DRNG {
|
||||
}
|
||||
|
||||
// Read implements the io.Reader interface
|
||||
func (d *DRNG) Read(p []byte) (int, error) {
|
||||
func (d *DRNG) Read(p []byte) (n int, err error) {
|
||||
return d.shake.Read(p)
|
||||
}
|
||||
|
||||
@@ -112,7 +79,7 @@ func (d *DRNG) Read(p []byte) (int, error) {
|
||||
func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
||||
// Validate the masterKey is a private key
|
||||
if !masterKey.IsPrivate() {
|
||||
return nil, ErrNotPrivateKey
|
||||
return nil, fmt.Errorf("master key must be a private key")
|
||||
}
|
||||
|
||||
// Derive the child key at the specified path
|
||||
@@ -131,12 +98,8 @@ func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, err
|
||||
return ecPrivKey.Serialize(), nil
|
||||
}
|
||||
|
||||
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the
|
||||
// BIP85 method
|
||||
func DeriveBIP85Entropy(
|
||||
masterKey *hdkeychain.ExtendedKey,
|
||||
path string,
|
||||
) ([]byte, error) {
|
||||
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the BIP85 method
|
||||
func DeriveBIP85Entropy(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
||||
// Get the child key bytes
|
||||
privKeyBytes, err := DeriveChildKey(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -152,10 +115,7 @@ func DeriveBIP85Entropy(
|
||||
}
|
||||
|
||||
// deriveChildKey derives a child key from a parent key using the given path
|
||||
func deriveChildKey(
|
||||
parent *hdkeychain.ExtendedKey,
|
||||
path string,
|
||||
) (*hdkeychain.ExtendedKey, error) {
|
||||
func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.ExtendedKey, error) {
|
||||
if path == "" || path == "m" || path == "/" {
|
||||
return parent, nil
|
||||
}
|
||||
@@ -181,12 +141,9 @@ func deriveChildKey(
|
||||
|
||||
// Parse the index
|
||||
var index uint32
|
||||
|
||||
_, err := fmt.Sscanf(component, "%d", &index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s", ErrInvalidPathComponent, component,
|
||||
)
|
||||
return nil, fmt.Errorf("invalid path component: %s", component)
|
||||
}
|
||||
|
||||
// Apply hardening if needed
|
||||
@@ -207,14 +164,8 @@ func deriveChildKey(
|
||||
}
|
||||
|
||||
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
||||
func DeriveBIP39Entropy(
|
||||
masterKey *hdkeychain.ExtendedKey,
|
||||
language, words, index uint32,
|
||||
) ([]byte, error) {
|
||||
path := fmt.Sprintf(
|
||||
"%s/%d'/%d'/%d'/%d'",
|
||||
BIP85_MASTER_PATH, AppBIP39, language, words, index,
|
||||
)
|
||||
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, AppBIP39, language, words, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -232,7 +183,6 @@ func DeriveBIP39Entropy(
|
||||
)
|
||||
|
||||
var bits int
|
||||
|
||||
switch words {
|
||||
case words12:
|
||||
bits = 128
|
||||
@@ -245,7 +195,7 @@ func DeriveBIP39Entropy(
|
||||
case words24:
|
||||
bits = 256
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %d", ErrInvalidWordCount, words)
|
||||
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
|
||||
}
|
||||
|
||||
// Truncate to the required number of bits (bytes = bits / 8)
|
||||
@@ -268,7 +218,6 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
||||
|
||||
// Convert to WIF format
|
||||
privKey, _ := btcec.PrivKeyFromBytes(keyBytes)
|
||||
|
||||
wif, err := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) // compressed=true
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create WIF: %w", err)
|
||||
@@ -278,10 +227,7 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
||||
}
|
||||
|
||||
// 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, AppXPRV, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
@@ -320,10 +266,10 @@ func DeriveXPRV(
|
||||
checksum := doubleSHA256(serializedBytes)[:4]
|
||||
|
||||
// Append checksum
|
||||
serializedBytes = append(serializedBytes, checksum...)
|
||||
serializedWithChecksum := append(serializedBytes, checksum...)
|
||||
|
||||
// Base58 encode
|
||||
xprvStr := base58.Encode(serializedBytes)
|
||||
xprvStr := base58.Encode(serializedWithChecksum)
|
||||
|
||||
// Parse the serialized xprv back to an ExtendedKey
|
||||
return hdkeychain.NewKeyFromString(xprvStr)
|
||||
@@ -338,12 +284,9 @@ func doubleSHA256(data []byte) []byte {
|
||||
}
|
||||
|
||||
// DeriveHex derives a raw hex string of specified length
|
||||
func DeriveHex(
|
||||
masterKey *hdkeychain.ExtendedKey,
|
||||
numBytes, index uint32,
|
||||
) (string, error) {
|
||||
func DeriveHex(masterKey *hdkeychain.ExtendedKey, numBytes, index uint32) (string, error) {
|
||||
if numBytes < 16 || numBytes > 64 {
|
||||
return "", ErrInvalidNumBytes
|
||||
return "", fmt.Errorf("numBytes must be between 16 and 64")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_HEX, numBytes, index)
|
||||
@@ -360,12 +303,9 @@ func DeriveHex(
|
||||
}
|
||||
|
||||
// DeriveBase64Password derives a password encoded in Base64
|
||||
func DeriveBase64Password(
|
||||
masterKey *hdkeychain.ExtendedKey,
|
||||
pwdLen, index uint32,
|
||||
) (string, error) {
|
||||
func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
||||
if pwdLen < 20 || pwdLen > 86 {
|
||||
return "", ErrInvalidBase64PwdLen
|
||||
return "", fmt.Errorf("pwdLen must be between 20 and 86")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_PWD64, pwdLen, index)
|
||||
@@ -383,22 +323,16 @@ func DeriveBase64Password(
|
||||
|
||||
// Slice to the desired password length
|
||||
if len(encodedStr) < int(pwdLen) {
|
||||
return "", fmt.Errorf(
|
||||
"derived password length %d %w %d",
|
||||
len(encodedStr), ErrPasswordTooShort, pwdLen,
|
||||
)
|
||||
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
||||
}
|
||||
|
||||
return encodedStr[:pwdLen], nil
|
||||
}
|
||||
|
||||
// DeriveBase85Password derives a password encoded in Base85
|
||||
func DeriveBase85Password(
|
||||
masterKey *hdkeychain.ExtendedKey,
|
||||
pwdLen, index uint32,
|
||||
) (string, error) {
|
||||
func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
||||
if pwdLen < 10 || pwdLen > 80 {
|
||||
return "", ErrInvalidBase85PwdLen
|
||||
return "", fmt.Errorf("pwdLen must be between 10 and 80")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, AppPWD85, pwdLen, index)
|
||||
@@ -413,21 +347,16 @@ func DeriveBase85Password(
|
||||
|
||||
// Slice to the desired password length
|
||||
if len(encoded) < int(pwdLen) {
|
||||
return "", fmt.Errorf(
|
||||
"encoded length %d %w %d",
|
||||
len(encoded), ErrEncodedTooShort, pwdLen,
|
||||
)
|
||||
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
||||
}
|
||||
|
||||
return encoded[:pwdLen], nil
|
||||
}
|
||||
|
||||
// encodeBase85WithRFC1924Charset encodes data using Base85 with the
|
||||
// RFC1924 character set
|
||||
// encodeBase85WithRFC1924Charset encodes data using Base85 with the RFC1924 character set
|
||||
func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||
// RFC1924 character set
|
||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||
"abcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||
|
||||
const (
|
||||
base85ChunkSize = 4 // Process 4 bytes at a time
|
||||
@@ -440,9 +369,7 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||
copy(padded, data)
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
// Each 4 bytes becomes 5 Base85 characters
|
||||
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize)
|
||||
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
|
||||
|
||||
// Process in 4-byte chunks
|
||||
for i := 0; i < len(padded); i += base85ChunkSize {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
155
script/bootstrap
155
script/bootstrap
@@ -1,155 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||
# otherwise a pinned version is installed via nvm (installing nvm
|
||||
# itself first, from a hash-verified release archive, never curl | sh).
|
||||
#
|
||||
# Uncomment the language sections in main() that apply to this repo.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
||||
YARN_VERSION="1.22.22"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
detect_pkgmgr() {
|
||||
[ -n "$PKGMGR" ] && return 0
|
||||
if command -v nix-env >/dev/null 2>&1; then
|
||||
PKGMGR="nix"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
PKGMGR="apt"
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
PKGMGR="brew"
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKGMGR="apk"
|
||||
else
|
||||
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PKGMGR" = "apt" ]; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||
pkg_install() {
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||
brew) brew install "$3" ;;
|
||||
apk) apk add --no-cache "$4" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
missing() {
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# verify_sha256 <file> <expected-hash>
|
||||
verify_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||
else
|
||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||
fi
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||
nvm_sh() {
|
||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||
# nvm prerequisites; nvm itself requires bash, so install it too
|
||||
if missing bash; then pkg_install bash bash bash bash; fi
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
||||
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
||||
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
||||
mkdir -p "$HOME/.nvm"
|
||||
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
ensure_node() {
|
||||
if ! missing node; then return 0; fi
|
||||
ensure_nvm
|
||||
nvm_sh "nvm install $NODE_VERSION"
|
||||
}
|
||||
|
||||
ensure_yarn() {
|
||||
if ! missing yarn; then return 0; fi
|
||||
if ! missing corepack; then
|
||||
corepack enable
|
||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
install_js_deps() {
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
yarn install --frozen-lockfile"
|
||||
else
|
||||
yarn install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Base tooling (every repo)
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
|
||||
# ---- JS / docs repos ----
|
||||
# ensure_node
|
||||
# ensure_yarn
|
||||
# install_js_deps
|
||||
|
||||
# ---- Go repos ----
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
|
||||
# package: download a specific release archive from GitHub and
|
||||
# verify its hash (verify_sha256), never curl | sh.
|
||||
if missing golangci-lint; then
|
||||
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
|
||||
fi
|
||||
go mod download
|
||||
|
||||
# ---- Python repos ----
|
||||
# if missing python3; then pkg_install python3 python3 python3 python3; fi
|
||||
# python3 -m venv .venv
|
||||
# ./.venv/bin/pip install -e '.[dev]'
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
15
script/check
15
script/check
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# Generic: usually needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Dockerfile runs script/check
|
||||
# (via make check), so a successful build implies all checks pass.
|
||||
# The Gitea workflow runs this on push. The memlock ulimit is required
|
||||
# because the test suite uses memguard, which mlocks memory.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --ulimit memlock=-1:-1 .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
12
script/fmt
12
script/fmt
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fmt: format all files (writes).
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go fmt ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fmt-check: check formatting (read-only). Same scope as
|
||||
# script/fmt, but fails instead of writing.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "Files need formatting:"
|
||||
gofmt -l .
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/install-precommit: install the git pre-commit hook that runs
|
||||
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
hook=".git/hooks/pre-commit"
|
||||
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
echo "pre-commit hook installed: runs script/precommit"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
14
script/lint
14
script/lint
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# CGO is required (Makefile exports this too)
|
||||
export CGO_ENABLED=1
|
||||
golangci-lint run --timeout 5m
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
||||
# extras run first: go mod tidy and go fmt, failing the commit if they
|
||||
# change go.mod or go.sum.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go mod tidy
|
||||
go fmt ./...
|
||||
git diff --exit-code -- go.mod go.sum ||
|
||||
{ echo "go mod tidy changed files; stage and retry" >&2; exit 1; }
|
||||
"$SCRIPT_DIR/check"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/projectname: output the name of this project. Our own
|
||||
# extension to scripts-to-rule-them-all. Other scripts that need the
|
||||
# name (e.g. script/docker) call this, so they can stay identical
|
||||
# across all repos.
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
echo "secret"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
14
script/setup
14
script/setup
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/setup: set up the repo for development after a fresh clone:
|
||||
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||
# Add any repo-specific initialization (db init, .env template) here.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/bootstrap"
|
||||
"$SCRIPT_DIR/install-precommit"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
15
script/test
15
script/test
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/test: run the test suite (vet first, verbose rerun on failure).
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# CGO is required (Makefile exports this too)
|
||||
export CGO_ENABLED=1
|
||||
go vet ./...
|
||||
go test ./... || go test -v ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user