Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d615ef612 |
@@ -1,9 +1,3 @@
|
|||||||
# .git is sent without its config. Without a VERSION build argument the
|
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
|
||||||
.git/config
|
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
secret
|
secret
|
||||||
coverage.out
|
coverage.out
|
||||||
@@ -16,9 +10,6 @@ coverage.out
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
+7
-29
@@ -1,34 +1,12 @@
|
|||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
**/.DS_Store
|
||||||
|
|
||||||
# Editors
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
*.bak
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.sublime-*
|
|
||||||
|
|
||||||
# Agent scratch (worktrees of this repo, created and destroyed by
|
|
||||||
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
|
||||||
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
|
||||||
# entry and must not be given a `**/` prefix on the way into one.
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Environment / secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# This repo. /secret is the built binary, anchored so that it does not
|
|
||||||
# also match the internal/secret/ package directory.
|
|
||||||
/secret
|
/secret
|
||||||
*.log
|
*.log
|
||||||
|
cli.test
|
||||||
|
vault.test
|
||||||
*.test
|
*.test
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|
||||||
|
# Stale files
|
||||||
|
.cursorrules
|
||||||
|
coverage.out
|
||||||
|
|||||||
+116
-86
@@ -1,98 +1,128 @@
|
|||||||
version: "2"
|
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:
|
run:
|
||||||
timeout: 5m
|
go: "1.24"
|
||||||
modules-download-mode: readonly
|
tests: false
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
|
||||||
enable:
|
enable:
|
||||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
# Additional linters requested
|
||||||
# left to `default: all`, because it carries the module policy below.
|
- testifylint # Checks usage of github.com/stretchr/testify
|
||||||
- gomodguard_v2
|
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
||||||
disable:
|
- tagliatelle # Checks the struct tags
|
||||||
# Genuinely incompatible with project patterns
|
- nlreturn # nlreturn checks for a new line before return and branch statements
|
||||||
- exhaustruct # Requires all struct fields
|
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
||||||
- godot # Requires comments to end with periods
|
- nestif # Reports deeply nested if statements
|
||||||
- wrapcheck # Too verbose for internal packages
|
- mnd # An analyzer to detect magic numbers
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- lll # Reports long lines
|
||||||
# Deprecated: the warning is attached to the old name, so it is
|
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||||
# silenced by disabling that name, not by enabling the successor.
|
- gochecknoglobals # Check that no global variables exist
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
# Default/existing linters that are commonly useful
|
||||||
settings:
|
- govet
|
||||||
|
- errcheck
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- revive
|
||||||
|
- gosec
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 120
|
||||||
funlen:
|
|
||||||
lines: 80
|
mnd:
|
||||||
statements: 50
|
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
|
||||||
cyclop:
|
checks:
|
||||||
max-complexity: 15
|
- argument
|
||||||
dupl:
|
- case
|
||||||
threshold: 100
|
- condition
|
||||||
depguard:
|
- operation
|
||||||
# Test-support code must not be compiled into the shipped binary. A
|
- return
|
||||||
# test-support package exists to hand a test privileges the program
|
- assign
|
||||||
# itself must never have, so a file that is not a test must not import
|
ignored-numbers:
|
||||||
# one. Test files, and the files inside a package whose directory name
|
- '0'
|
||||||
# ends in `test`, are where that code belongs, and are exempt.
|
- '1'
|
||||||
#
|
- '2'
|
||||||
# The deny list below is the one part of this file a repository is
|
- '8'
|
||||||
# expected to extend, and the only part it may. depguard matches an
|
- '16'
|
||||||
# import path against a list of prefixes, so it cannot be told "any path
|
- '40' # GPG fingerprint length
|
||||||
# whose last segment ends in test"; a repository's own test-support
|
- '64'
|
||||||
# packages have to be named here one at a time, by full import path,
|
- '128'
|
||||||
# under a module path that differs from repository to repository. Add
|
- '256'
|
||||||
# them; change nothing else.
|
- '512'
|
||||||
|
- '1024'
|
||||||
|
- '2048'
|
||||||
|
- '4096'
|
||||||
|
|
||||||
|
nestif:
|
||||||
|
min-complexity: 4
|
||||||
|
|
||||||
|
nlreturn:
|
||||||
|
block-size: 2
|
||||||
|
|
||||||
|
revive:
|
||||||
rules:
|
rules:
|
||||||
test-support:
|
- name: var-naming
|
||||||
list-mode: lax
|
arguments:
|
||||||
files:
|
- []
|
||||||
- "$all"
|
- []
|
||||||
- "!$test"
|
- "upperCaseConst=true"
|
||||||
- "!**/*test/**"
|
|
||||||
deny:
|
tagliatelle:
|
||||||
- pkg: net/http/httptest
|
case:
|
||||||
desc: >-
|
rules:
|
||||||
Test-support code belongs in test files and in packages whose
|
json: snake
|
||||||
directory name ends in test, not in the shipped binary.
|
yaml: snake
|
||||||
# Only decisions already recorded in the Go package defaults are
|
xml: snake
|
||||||
# listed here. Every entry matches the module path exactly.
|
bson: snake
|
||||||
gomodguard_v2:
|
|
||||||
blocked:
|
testifylint:
|
||||||
- module: github.com/rs/zerolog
|
enable-all: true
|
||||||
recommendations:
|
|
||||||
- log/slog
|
usetesting: {}
|
||||||
reason: "Structured logging is stdlib log/slog."
|
|
||||||
# One entry per pre-fork module path, because the later releases
|
|
||||||
# are separate paths. A prefix match would be shorter but would
|
|
||||||
# also reach github.com/go-redis/redismock, the test double for
|
|
||||||
# the successor these entries recommend.
|
|
||||||
- module: github.com/go-redis/redis
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/go-redis/redis/v7
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/go-redis/redis/v8
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/sergi/go-diff
|
|
||||||
recommendations:
|
|
||||||
- github.com/aymanbagabas/go-udiff
|
|
||||||
reason: "No unified diff output; use go-udiff."
|
|
||||||
- module: github.com/hexops/gotextdiff
|
|
||||||
recommendations:
|
|
||||||
- github.com/aymanbagabas/go-udiff
|
|
||||||
reason: "Unmaintained fork; use go-udiff."
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 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
|
||||||
|
|||||||
+4
-29
@@ -1,24 +1,15 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
# golangci/golangci-lint v2.1.6 (2026-03-10)
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
|
|
||||||
# below run again on each build, an unchanged tree included, while the
|
|
||||||
# steps above stay cached. ARG is per stage: the build stage declares it too.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
# Not make lint or make lint-darwin: script/lint and script/lint-darwin are
|
RUN make lint
|
||||||
# docker builds, which cannot run in here. These are their commands.
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
|
||||||
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
|
||||||
# Build stage — tests and compilation
|
# Build stage — tests and compilation
|
||||||
# golang 1.24.13-alpine (2026-03-10)
|
# golang 1.24.13-alpine (2026-03-10)
|
||||||
@@ -33,26 +24,10 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# As in the lint stage: the RUN steps below run again on each script/cibuild.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
RUN make build
|
||||||
# The version stamped into the binary: the VERSION build argument when one
|
|
||||||
# is given, otherwise `git describe --tags --always` of the .git the build
|
|
||||||
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
|
|
||||||
# one, the short commit when no tag is reachable. A context that carries .git
|
|
||||||
# and still yields no version fails the build.
|
|
||||||
ARG VERSION
|
|
||||||
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
|
||||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
|
||||||
[ "$version" = unknown ]; }; then \
|
|
||||||
echo "no version could be derived although the build context carries .git" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
make build VERSION="${version:-dev}"
|
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine 3.23 (2026-03-10)
|
# alpine 3.23 (2026-03-10)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Lint image, built by script/lint and script/lint-darwin: golangci-lint runs
|
|
||||||
# as a build step, so a successful build is a clean lint. Works where the
|
|
||||||
# docker daemon is remote and bind mounts are impossible.
|
|
||||||
|
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# script/lint rebuilds this stage on every run, by this name; the module
|
|
||||||
# download above stays cached.
|
|
||||||
FROM deps AS lint
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
|
||||||
# script/lint-darwin rebuilds this stage on every run, by this name. It
|
|
||||||
# checks the code as a macOS build compiles it, but with cgo off, which
|
|
||||||
# leaves out the files that need cgo on macOS (see script/lint-darwin).
|
|
||||||
FROM deps AS lint-darwin
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
|
||||||
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
|
export DOCKER_HOST := ssh://root@ber1app1.local
|
||||||
|
|
||||||
.PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \
|
# Version information
|
||||||
check docker docker-run clean install hooks
|
VERSION := 0.1.0
|
||||||
|
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||||
|
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
|
||||||
|
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
|
||||||
|
|
||||||
|
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
|
||||||
|
|
||||||
default: check
|
default: check
|
||||||
|
|
||||||
@@ -11,9 +17,13 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
|
build: ./secret
|
||||||
build:
|
|
||||||
@script/build
|
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
|
||||||
|
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
@@ -24,10 +34,6 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
|
|
||||||
lint-darwin:
|
|
||||||
@script/lint-darwin
|
|
||||||
|
|
||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ docker-run:
|
|||||||
clean:
|
clean:
|
||||||
rm -f ./secret
|
rm -f ./secret
|
||||||
|
|
||||||
install: build
|
install: ./secret
|
||||||
cp ./secret $(HOME)/bin/secret
|
cp ./secret $(HOME)/bin/secret
|
||||||
|
|
||||||
fmt-check:
|
fmt-check:
|
||||||
|
|||||||
@@ -70,24 +70,6 @@ make build
|
|||||||
|
|
||||||
## Commands Reference
|
## Commands Reference
|
||||||
|
|
||||||
### Confirmation Before Removal
|
|
||||||
|
|
||||||
`secret rm`, `secret version rm`, `secret vault remove` and
|
|
||||||
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
|
|
||||||
each one first asks `[y/N]`, naming exactly what it is about to remove, and
|
|
||||||
goes ahead only on `y` or `yes`; any other answer, a bare Enter included,
|
|
||||||
cancels and removes nothing. The question is asked only after the command's
|
|
||||||
checks have passed, and before it changes anything.
|
|
||||||
|
|
||||||
Whether to ask is decided by stdin, where the answer is read from, so
|
|
||||||
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
|
|
||||||
script or a CI job, nobody is there to answer: the command fails at once,
|
|
||||||
removes nothing, and says to pass `--force`.
|
|
||||||
|
|
||||||
`--force` (`-f`) removes without asking, whatever the command removes: a vault
|
|
||||||
that holds secrets and the last unlocker of a vault included. Scripts that
|
|
||||||
remove things pass `--force`.
|
|
||||||
|
|
||||||
### Initialization
|
### Initialization
|
||||||
|
|
||||||
#### `secret init`
|
#### `secret init`
|
||||||
@@ -109,22 +91,19 @@ Lists all available vaults. The current vault is marked.
|
|||||||
|
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
|
|
||||||
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
|
|
||||||
are allowed, and a name must not be empty, `.` or `..`.
|
|
||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|
||||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a vault and all its secrets. It first asks
|
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`,
|
||||||
for confirmation, naming the vault and how many secrets it holds (see
|
this command does not ask for confirmation.
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
|
|
||||||
cannot be removed. Removing the current vault makes another vault the current
|
|
||||||
one.
|
|
||||||
|
|
||||||
- `--force, -f`: Remove without asking, also a vault that contains secrets
|
Requires --force if the vault contains secrets. With --force, will
|
||||||
|
automatically switch to another vault if removing the current one.
|
||||||
|
|
||||||
|
- `--force, -f`: Force removal even if vault contains secrets
|
||||||
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
@@ -134,9 +113,7 @@ one.
|
|||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
|
|
||||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/`
|
**Secret Name Format:** `[a-z0-9\.\-\_\/]+`
|
||||||
are allowed, and a name must not be empty, start with `.` or `/`, end with
|
|
||||||
`/`, contain `//`, or have `..` as a path segment.
|
|
||||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||||
|
|
||||||
@@ -150,12 +127,9 @@ Retrieves and outputs a secret value to stdout.
|
|||||||
Lists all secrets in the current vault. Optional filter for substring
|
Lists all secrets in the current vault. Optional filter for substring
|
||||||
matching.
|
matching.
|
||||||
|
|
||||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks
|
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
for confirmation, naming the secret, its vault and how many versions it has
|
|
||||||
(see [Confirmation Before Removal](#confirmation-before-removal)).
|
|
||||||
- `--force, -f`: Remove without asking
|
|
||||||
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
||||||
|
|
||||||
@@ -163,9 +137,6 @@ for confirmation, naming the secret, its vault and how many versions it has
|
|||||||
|
|
||||||
Moves or renames a secret within the current vault.
|
Moves or renames a secret within the current vault.
|
||||||
- Fails if the destination already exists
|
- Fails if the destination already exists
|
||||||
- Fails if the destination is the source under another name, such as `foo`
|
|
||||||
for `Foo` on a case-insensitive filesystem (the macOS default); there, to
|
|
||||||
change only the case of a name, move the secret to a third name first
|
|
||||||
- Preserves all versions and metadata
|
- Preserves all versions and metadata
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
@@ -179,12 +150,10 @@ Lists all versions of a secret showing creation time, status, and validity perio
|
|||||||
Promotes a specific version to current by updating the symlink. Does not
|
Promotes a specific version to current by updating the symlink. Does not
|
||||||
modify any timestamps, allowing for rollback scenarios.
|
modify any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
**DANGER**: Permanently removes a specific version of a secret. Like Unix
|
||||||
for confirmation, naming the version, the secret and its vault (see
|
`rm`, this command does not ask for confirmation.
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)).
|
|
||||||
- `--force, -f`: Remove without asking
|
|
||||||
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||||
- Cannot remove the current version (must promote another version first)
|
- Cannot remove the current version (must promote another version first)
|
||||||
|
|
||||||
@@ -220,20 +189,12 @@ Creates a new unlocker of the specified type:
|
|||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
||||||
|
|
||||||
A vault has one passphrase unlocker: adding one replaces the one the vault
|
|
||||||
has, which is removed only once the new one is the current unlocker.
|
|
||||||
|
|
||||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
|
||||||
naming the unlocker and its vault and saying whether it is the vault's last
|
does not ask for confirmation. Cannot remove the last unlocker if the vault
|
||||||
unlocker; for the last one it says how many secrets the vault holds and warns
|
has secrets unless --force is used.
|
||||||
that the vault then opens only with its mnemonic (see
|
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
|
||||||
directory that `secret unlocker list` skips with a warning, because its
|
|
||||||
metadata cannot be read or parsed, is removed by the directory name the
|
|
||||||
warning gives.
|
|
||||||
- `--force, -f`: Remove without asking, even the last unlocker
|
|
||||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||||
vault data will be PERMANENTLY INACCESSIBLE
|
vault data will be PERMANENTLY INACCESSIBLE
|
||||||
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
|
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
|
||||||
@@ -272,8 +233,8 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
├── vaults.d/
|
├── vaults.d/
|
||||||
│ ├── default/
|
│ ├── default/
|
||||||
│ │ ├── unlockers.d/
|
│ │ ├── unlockers.d/
|
||||||
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
|
│ │ │ ├── passphrase/ # Passphrase unlocker
|
||||||
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
|
│ │ │ └── pgp/ # PGP unlocker
|
||||||
│ │ ├── secrets.d/
|
│ │ ├── secrets.d/
|
||||||
│ │ │ ├── api%key/ # Secret: api/key
|
│ │ │ ├── api%key/ # Secret: api/key
|
||||||
│ │ │ │ ├── versions/
|
│ │ │ │ ├── versions/
|
||||||
@@ -289,7 +250,7 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ └── current -> versions/20231215.001
|
│ │ │ └── current -> versions/20231215.001
|
||||||
│ │ ├── vault-metadata.json # Vault metadata
|
│ │ ├── vault-metadata.json # Vault metadata
|
||||||
│ │ ├── pub.age # Long-term public key
|
│ │ ├── pub.age # Long-term public key
|
||||||
│ │ └── current-unlocker # Current unlocker's directory name
|
│ │ └── current-unlocker -> ../unlockers.d/passphrase
|
||||||
│ └── work/
|
│ └── work/
|
||||||
│ ├── unlockers.d/
|
│ ├── unlockers.d/
|
||||||
│ ├── secrets.d/
|
│ ├── secrets.d/
|
||||||
@@ -347,18 +308,6 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
||||||
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
||||||
|
|
||||||
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
|
|
||||||
they hold. Other processes running as the same user can read a process's
|
|
||||||
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
|
||||||
shell or script that sets them inherits them, `gpg` included. Set on a command
|
|
||||||
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
|
||||||
each one as soon as it has read it, so that the programs it runs itself, such
|
|
||||||
as `gpg`, do not inherit it, but that erases nothing: the environment the
|
|
||||||
process started with, and its memory, still hold the value. The interactive
|
|
||||||
prompt, which every command except `secret vault import` offers when the
|
|
||||||
variable is not set, is the safer default; `secret vault import` has no prompt
|
|
||||||
and needs both variables.
|
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
@@ -403,7 +352,7 @@ secret list
|
|||||||
secret get database/prod/password
|
secret get database/prod/password
|
||||||
secret get services/api/key
|
secret get services/api/key
|
||||||
|
|
||||||
# Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
|
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
secret remove ssh/servers/web01
|
secret remove ssh/servers/web01
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -426,7 +375,7 @@ echo "personal-email-pass" | secret add email/password
|
|||||||
# List all vaults
|
# List all vaults
|
||||||
secret vault list
|
secret vault list
|
||||||
|
|
||||||
# Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
|
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
secret vault remove personal --force
|
secret vault remove personal --force
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -444,7 +393,7 @@ secret unlocker list
|
|||||||
# Select a specific unlocker
|
# Select a specific unlocker
|
||||||
secret unlocker select <unlocker-id>
|
secret unlocker select <unlocker-id>
|
||||||
|
|
||||||
# Remove an unlocker ⚠️ 🛑 (asks first!)
|
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
|
||||||
secret unlocker remove <unlocker-id>
|
secret unlocker remove <unlocker-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -457,7 +406,7 @@ secret version list database/prod/password
|
|||||||
# Promote an older version to current
|
# Promote an older version to current
|
||||||
secret version promote database/prod/password 20231215.001
|
secret version promote database/prod/password 20231215.001
|
||||||
|
|
||||||
# Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
|
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
secret version remove database/prod/password 20231214.001
|
secret version remove database/prod/password 20231214.001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -498,10 +447,6 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
- **macOS**: Full support including Keychain and Secure Enclave integration
|
- **macOS**: Full support including Keychain and Secure Enclave integration
|
||||||
- **Linux**: Full support (excluding macOS-specific features)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
|
|
||||||
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
|
|
||||||
build without cgo, such as one cross-compiled from Linux, offers them but fails
|
|
||||||
to add or use them.
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Threat Model
|
### Threat Model
|
||||||
@@ -549,36 +494,23 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (Go, Go module
|
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
|
||||||
download), idempotently; golangci-lint is not installed, it runs in
|
module download), idempotently
|
||||||
docker
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
- `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
- `script/projectname` — output the project name (`secret`); used by
|
- `script/projectname` — output the project name (`secret`); used by
|
||||||
other scripts such as `script/docker`
|
other scripts such as `script/docker`
|
||||||
- `script/build` — build the `secret` binary into the repo root, stamping
|
|
||||||
the version (`VERSION` from the environment, else `git describe`) and
|
|
||||||
the git commit
|
|
||||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
||||||
failure)
|
failure)
|
||||||
- `script/lint` — run `golangci-lint` in docker only: builds
|
- `script/lint` — run `golangci-lint`
|
||||||
`Dockerfile.lint`, where the linter is a build step that runs on every
|
|
||||||
call, also on an unchanged tree
|
|
||||||
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
|
|
||||||
the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
|
|
||||||
build never compiles; cgo is off, so the keychain unlocker's calls into
|
|
||||||
the keychain (`internal/secret/keychainunlocker_cgo.go`, and
|
|
||||||
`keychainunlocker_test.go`) and the Secure Enclave bindings
|
|
||||||
(`internal/macse`) are not checked
|
|
||||||
- `script/fmt` — format all Go code (writes)
|
- `script/fmt` — format all Go code (writes)
|
||||||
- `script/fmt-check` — check formatting without writing
|
- `script/fmt-check` — check formatting without writing
|
||||||
- `script/check` — run `script/test`, `script/lint`,
|
- `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/lint-darwin`, and `script/fmt-check`
|
`script/fmt-check`
|
||||||
- `script/docker` — build the Docker image tagged with the project name
|
- `script/docker` — build the Docker image tagged with the project name
|
||||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
||||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
||||||
checks), with a new `CHECK_EPOCH` build argument on every run so the
|
checks)
|
||||||
checks run again on an unchanged tree
|
|
||||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
||||||
then `script/check`
|
then `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
|
|||||||
@@ -25,292 +25,15 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
- 2026-08-09: `script/test` now runs
|
||||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
`go test -timeout 30s -race -cover ./...` and, on failure, reruns
|
||||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
verbosely and then exits non-zero, so a test that fails once and
|
||||||
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
passes on the retry can no longer produce a green build (closes #32).
|
||||||
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
|
Enabling `-race` showed the suite cannot meet the 20-second policy
|
||||||
key again if encrypting with it or writing the unlocker then fails.
|
budget: `internal/cli` exceeds the 30-second timeout because the
|
||||||
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
|
99MB/100MB boundary cases in `secrets_size_test.go` run roughly 10x
|
||||||
among them, before it stores the item in the keychain, and deletes the item
|
slower under race instrumentation. No flags were weakened and no
|
||||||
again if moving the unlocker into place then fails. A failure to delete is
|
tests skipped; the conflict is tracked in #52 for a decision.
|
||||||
reported along with the first error. The tests of this run only on macOS:
|
|
||||||
the Secure Enclave one in a build with cgo on a Mac with a Secure Enclave,
|
|
||||||
the keychain one in a build with cgo.
|
|
||||||
- 2026-10-04: An age identity's private key goes into a locked buffer
|
|
||||||
through `secret.IdentityToLockedBuffer` everywhere
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
|
|
||||||
when a passphrase, PGP, keychain or Secure Enclave unlocker is created,
|
|
||||||
the new unlocker's own key, a new secret version's key, and the key
|
|
||||||
`secret encrypt` generates. Before, each place converted the string age
|
|
||||||
returns to bytes and left the string in ordinary memory. The function
|
|
||||||
moves the string's own bytes into the buffer, which overwrites them; the
|
|
||||||
copies age makes while writing the string remain, as its comment says.
|
|
||||||
The 1.0 memory-security entry below no longer lists these places,
|
|
||||||
`internal/cli/crypto.go` among them, nor `version.go:155`, which was
|
|
||||||
`internal/secret/version.go`, not `internal/cli/version.go`.
|
|
||||||
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
|
|
||||||
`golangci-lint` in docker on the code as a macOS build compiles it
|
|
||||||
(`GOOS=darwin`), with cgo off
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
|
|
||||||
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
|
|
||||||
Before, CI on Linux never compiled the files built only for macOS. Compiling
|
|
||||||
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
|
|
||||||
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions
|
|
||||||
that call `go-keychain` moved from `keychainunlocker.go` to
|
|
||||||
`keychainunlocker_cgo.go`, built only with cgo on macOS like
|
|
||||||
`macse_darwin.go`. A macOS build without cgo, which before did not compile,
|
|
||||||
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
|
|
||||||
say the keychain or Secure Enclave needs a macOS build with cgo. The check
|
|
||||||
covers the rest of the keychain unlocker, the Secure Enclave unlocker and
|
|
||||||
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
|
|
||||||
findings are fixed. For the length and complexity limits, parts of
|
|
||||||
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
|
|
||||||
into functions of their own, and the Secure Enclave unlocker derives the
|
|
||||||
long-term key from the mnemonic through the same function as the keychain
|
|
||||||
unlocker instead of a copy of it. Lines over 88 columns in the files the
|
|
||||||
check cannot see are wrapped.
|
|
||||||
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
|
|
||||||
`secret unlocker remove` ask `[y/N]` before removing anything
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
|
|
||||||
secret, its vault and its version count; the version, secret and vault; the
|
|
||||||
vault and its secret count; the unlocker, its vault and whether it is the
|
|
||||||
last, and for the last the vault's secret count and that the vault then
|
|
||||||
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
|
|
||||||
`--force`, a command whose stdin is not a terminal fails at once. `--force`
|
|
||||||
(now also on `rm` and `version rm`) removes without asking; it replaces the
|
|
||||||
old refusals to remove a vault with secrets or the last unlocker of one
|
|
||||||
without `--force`, which the question now covers. The checks run, and the
|
|
||||||
question is asked, before the state directory lock is taken; under the
|
|
||||||
lock the checks run again, and if they would ask a different question,
|
|
||||||
nothing is removed. `secret rm` fails when it cannot count the versions.
|
|
||||||
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
|
||||||
current unlocker that cannot open the vault
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
|
||||||
directory of its own, named with the time to the nanosecond:
|
|
||||||
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
|
|
||||||
Enclave unlocker the keychain item or Secure Enclave key, which names the
|
|
||||||
directory, carries the time instead of the day. `secret.WriteDir` fails on a
|
|
||||||
directory that exists instead of writing into it. `unlocker add passphrase`
|
|
||||||
writes the new unlocker, makes it current, and only then removes the vault's
|
|
||||||
other passphrase unlockers; a crash between the last two steps leaves the old
|
|
||||||
one beside the new, and the old passphrase still opens the vault through it
|
|
||||||
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
|
|
||||||
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
|
|
||||||
another of its type is added beside it instead of replacing it.
|
|
||||||
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
|
|
||||||
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
|
|
||||||
unset at once, so that no program the command runs, `gpg` included,
|
|
||||||
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
|
|
||||||
the command reads the environment; the buffers are passed down:
|
|
||||||
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
|
||||||
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
|
||||||
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
|
||||||
constructors take both. `CreatePGPUnlocker` sets both on the vault it
|
|
||||||
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
|
|
||||||
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
|
|
||||||
`vault create` no longer put the mnemonic into the environment. Unsetting
|
|
||||||
erases nothing: the starting environment (`/proc/<pid>/environ`) and
|
|
||||||
memory still hold the value. The README warns against both variables.
|
|
||||||
- 2026-10-04: `.golangci.yml` is again the canonical file from
|
|
||||||
`sneak/prompts`, byte for byte
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
|
|
||||||
in place of the deprecated `gomodguard`, so the lint no longer warns,
|
|
||||||
and enables `depguard` with a rule that keeps `net/http/httptest` out of
|
|
||||||
non-test files. Neither raised a finding in this repo.
|
|
||||||
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
|
|
||||||
the vault's long-term key as adding a passphrase unlocker does, with the
|
|
||||||
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
|
|
||||||
mnemonic, checked against the vault, or else from the current unlocker.
|
|
||||||
Before, it used the keychain unlocker's helper, which on every platform
|
|
||||||
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
|
|
||||||
key, getting the long-term key once from the mnemonic and once from a
|
|
||||||
passphrase unlocker, and reads a secret through the new unlocker.
|
|
||||||
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
|
||||||
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
|
||||||
state the rule. `vault create`, `vault import`, `vault select`,
|
|
||||||
`vault remove`, both vault names of `mv` and shell completion of a
|
|
||||||
`vault:secret` argument check the name as typed with
|
|
||||||
`vault.ValidateVaultName` before building any path from it. Before,
|
|
||||||
`vault import ..` wrote a long-term key and an unlocker into the state
|
|
||||||
directory itself, and `vault select ..` made that the current vault.
|
|
||||||
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
|
||||||
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
|
||||||
current time as the `CHECK_EPOCH` build argument, which both the lint
|
|
||||||
and the build stage of the `Dockerfile` declare after their module
|
|
||||||
download, so the `RUN` steps below the argument run again on each
|
|
||||||
build while the base images and module downloads stay cached. Before,
|
|
||||||
a second run on the same tree took every check from the build cache
|
|
||||||
and reported success having run nothing.
|
|
||||||
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
|
||||||
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
|
||||||
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for
|
|
||||||
its duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
|
||||||
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
|
|
||||||
and encrypt everything before writing anything. All four unlocker
|
|
||||||
types write their files through `secret.WriteDir`: a new unlocker is
|
|
||||||
built in a temporary directory, renamed into place when complete and
|
|
||||||
removed on a failure.
|
|
||||||
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
|
|
||||||
skip, with the warning `unlocker list` gives, an unlocker directory
|
|
||||||
whose metadata file cannot be checked for, read or parsed, instead of
|
|
||||||
failing when it sorts before the unlocker asked for. Such a directory,
|
|
||||||
or one without a metadata file, is removed by its directory name, the
|
|
||||||
name the warning gives; only the directory is removed, since its type
|
|
||||||
is unknown. Removing one whose metadata file is missing or corrupt
|
|
||||||
never counts as removing the last unlocker. Removing one whose metadata
|
|
||||||
file cannot be checked for or read always does, since it may be the
|
|
||||||
only working unlocker, so in a vault with secrets it needs `--force`.
|
|
||||||
- 2026-10-04: A failed command prints its error once, without the usage
|
|
||||||
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
|
|
||||||
still printed for a command called wrongly: wrong number of arguments,
|
|
||||||
unknown flag, bad flag value, missing required flag, or flags that
|
|
||||||
break a flag group (mutually exclusive, required together, one
|
|
||||||
required). The root command's `PersistentPreRunE` turns usage off.
|
|
||||||
Cobra checks arguments and flag values before that hook but required
|
|
||||||
flags and flag groups only after it, so the hook checks those two
|
|
||||||
first. Root `SilenceUsage` would have hidden usage for all of these.
|
|
||||||
- 2026-10-04: `secret get` keeps the secret in locked memory until it
|
|
||||||
writes it out (https://git.eeqj.de/sneak/secret/issues/37):
|
|
||||||
`Vault.GetSecret` and `Vault.GetSecretVersion` return a
|
|
||||||
`*memguard.LockedBuffer`, which every caller destroys, and `secret get`
|
|
||||||
writes its bytes straight to stdout, still with no trailing newline.
|
|
||||||
Before, the value was copied into ordinary memory that nothing wiped,
|
|
||||||
and `get --version` also wrote it to the debug log.
|
|
||||||
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
|
|
||||||
targets use the local docker daemon, or whatever `DOCKER_HOST` the
|
|
||||||
environment sets. `make build` calls the new `script/build`, which
|
|
||||||
stamps the version (`VERSION` from the environment, else
|
|
||||||
`git describe`) and the git commit as before. `build`, `clean`,
|
|
||||||
`install` and `docker-run` are in `.PHONY`; `make install` depends on
|
|
||||||
`build`. The `vet` target is gone: `script/test` runs `go vet` first.
|
|
||||||
- 2026-10-04: `.gitignore` is the org's standard file, which ignores
|
|
||||||
`.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus
|
|
||||||
this repo's `/secret`, `*.log`, `*.test` and `settings.local.json`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also
|
|
||||||
leaves out `node_modules`; `.git` stays in the build context for the
|
|
||||||
version stamp.
|
|
||||||
- 2026-10-04: `secret init` refuses when the default vault exists, and
|
|
||||||
`secret vault create NAME` when `NAME` does, with "vault NAME already
|
|
||||||
exists", before writing anything. The check is in `vault.CreateVault`,
|
|
||||||
which both commands call while holding the state directory lock, so two
|
|
||||||
creates of one vault at once cannot both pass the check. Before, either
|
|
||||||
command replaced the vault's metadata, passphrase unlocker and
|
|
||||||
`longterm.age`, so none of its secrets could be decrypted any more. Both
|
|
||||||
commands now ask for the unlocker passphrase before creating the vault,
|
|
||||||
so one stopped at that prompt leaves no vault behind.
|
|
||||||
- 2026-10-04: The `internal/cli` tests are back to about their time
|
|
||||||
before the state directory lock
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/80). The test that each
|
|
||||||
changing command waits for the lock releases it as soon as it sees the
|
|
||||||
command waiting there, instead of after a fixed 100 ms. The two vaults
|
|
||||||
with passphrase unlockers that the path and move tests start from are
|
|
||||||
made once and copied for each test.
|
|
||||||
- 2026-10-04: `secret mv` rejects a move whose destination is the source
|
|
||||||
under another name, such as `foo` for `Foo` on a case-insensitive
|
|
||||||
filesystem (the macOS default) or a name reached through a symbolic
|
|
||||||
link, before changing anything, with or without `--force`, within a
|
|
||||||
vault and between vaults; before, `--force` removed the destination and
|
|
||||||
so deleted the secret. A rename that changes only letter case works on a
|
|
||||||
case-sensitive filesystem as before.
|
|
||||||
- 2026-10-04: Lint runs only in docker: `script/lint` builds
|
|
||||||
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on
|
|
||||||
every run (`--no-cache-filter`), so an unchanged tree is linted too;
|
|
||||||
the module download stays cached. `script/bootstrap` no longer
|
|
||||||
installs golangci-lint, and the `Dockerfile` lint stage calls it
|
|
||||||
directly instead of `make lint`. `golangci-lint config verify` is not
|
|
||||||
run: it fetches its schema live over unpinned HTTPS.
|
|
||||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
|
||||||
no longer panics: `GetID()` warns with the unlocker's directory and
|
|
||||||
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
|
||||||
unlocker whose metadata file cannot be checked for, read or parsed
|
|
||||||
instead of failing, so `secret unlocker list` still lists the others;
|
|
||||||
the listing's ID lookup no longer warns about that directory again.
|
|
||||||
- 2026-10-03: `secret mv` rejects a move whose destination is the
|
|
||||||
source (`mv --force x x`, `mv --force work:x work:`, or an empty
|
|
||||||
destination, which defaults to the source name) before changing
|
|
||||||
anything; before, `--force` removed the destination first and so
|
|
||||||
deleted the secret. Every vault name given with `vault:` must be one
|
|
||||||
of the existing vaults by exact name, so `work:x work/:x` is rejected
|
|
||||||
instead of being taken for a move between two vaults. A move within a
|
|
||||||
named vault no longer makes that vault the current one, whether it
|
|
||||||
succeeds or fails.
|
|
||||||
- 2026-10-03: Commands that change the state directory hold one lock
|
|
||||||
(`flock` on `lock` in the state directory; a mutex on the in-memory
|
|
||||||
test filesystem), so concurrent commands no longer lose versions or
|
|
||||||
race on the current pointers. Every file is written through
|
|
||||||
`secret.WriteFileAtomic` (temporary file, sync, rename), so no file
|
|
||||||
is ever half-written and `current`, `currentvault` and
|
|
||||||
`current-unlocker` never go missing. New versions, new secrets and
|
|
||||||
cross-vault copies are built in a temporary directory and renamed
|
|
||||||
into place, and removals rename out of the way first, so a version
|
|
||||||
or secret is never half-added and never half-removed. An
|
|
||||||
interrupted command can still leave:
|
|
||||||
- from `init` or `vault create` killed after the passphrase prompt
|
|
||||||
but before the unlocker is written, a vault with no unlocker,
|
|
||||||
which `vault create` has already made the current vault;
|
|
||||||
- data under a `.tmp-` name in the state directory: a secret,
|
|
||||||
version or unlocker being added, or the secret, version, unlocker
|
|
||||||
or vault being removed, encrypted keys included. Nothing deletes
|
|
||||||
it; it must be deleted by hand
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/75).
|
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an
|
|
||||||
error naming the path and cause when they cannot read what they
|
|
||||||
inspect, instead of reading the failure as "nothing there": the
|
|
||||||
duplicate check before `unlocker add pgp` (an unreadable
|
|
||||||
`unlockers.d` or unlocker metadata file), the secret count that
|
|
||||||
guards removing the last unlocker and removing a vault, and the
|
|
||||||
existing long-term key check before `vault import`.
|
|
||||||
- 2026-10-03: `version rm`, `version promote` and `get --version`
|
|
||||||
accept a version only if it is one of the versions `version list`
|
|
||||||
lists for that secret, compared as typed before any path is built
|
|
||||||
(`secret.VersionExists`), and touch nothing otherwise. An empty
|
|
||||||
`--version` is rejected instead of meaning the current version.
|
|
||||||
Before, `secret version rm x ../../..` deleted the whole vault,
|
|
||||||
`secret version rm x ..` the secret, and `.` or `""` every version.
|
|
||||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
|
||||||
the exit code after its deferred `memguard.Purge()` has run, and only
|
|
||||||
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
|
||||||
handler, which wipes every buffer before exiting; when the process is
|
|
||||||
in the terminal's foreground process group it first restores the
|
|
||||||
terminal settings from startup, so an interrupted passphrase prompt no
|
|
||||||
longer leaves echo off.
|
|
||||||
- 2026-10-03: Every command that builds a path from a secret name
|
|
||||||
checks the name first with `vault.ValidateSecretName` and touches
|
|
||||||
nothing when it is invalid: `rm`, `mv` (both names, within a vault
|
|
||||||
and between vaults, before switching the current vault), `import`,
|
|
||||||
`version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error
|
|
||||||
and `README.md` state the naming rule. Before, `secret rm ..`
|
|
||||||
deleted the whole vault and `secret rm .` every secret in it.
|
|
||||||
- 2026-10-03: The keychain unlocker's age key passphrase stays in
|
|
||||||
locked memory: it is generated into a locked buffer, and the
|
|
||||||
keychain JSON is written and read by `KeychainData` code in
|
|
||||||
`internal/secret/keychaindata.go` (tested on Linux) without
|
|
||||||
`encoding/json` holding it; the JSON field names are unchanged.
|
|
||||||
- 2026-10-02: A plain `docker build .` builds again: the size tests
|
|
||||||
skip a case that needs more locked memory than the process can
|
|
||||||
lock, and run every case under `script/cibuild`. The image stamps the
|
|
||||||
`VERSION` build argument, else `git describe --tags --always`, into
|
|
||||||
`Version`, and fails if `.git` is present but yields no version;
|
|
||||||
`make build` stamps `git describe` too, not a fixed `0.1.0`.
|
|
||||||
`.dockerignore` keeps `.git/config` out; `script/docker` is the
|
|
||||||
canonical copy.
|
|
||||||
- 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,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||||
@@ -336,36 +59,32 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
- Compliance (after Next Step lands): keep main green under the new
|
- Compliance (after Next Step lands): keep main green under the new
|
||||||
.gitea workflow; run make check before every merge.
|
.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).
|
|
||||||
- CI does not compile, lint or test the files built only with cgo on
|
|
||||||
macOS, since compiling them needs Apple's SDK:
|
|
||||||
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
|
|
||||||
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
|
|
||||||
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
|
|
||||||
never run on them, so it would likely find more there than the line
|
|
||||||
lengths. No macOS test runs in CI. A macOS runner would cover all of it
|
|
||||||
(asked on https://git.eeqj.de/sneak/secret/issues/50).
|
|
||||||
- Merge secure-enclave-unlocker to main once review is done.
|
- Merge secure-enclave-unlocker to main once review is done.
|
||||||
- 1.0 critical security blockers (from repo TODO.md):
|
- 1.0 critical security blockers (from repo TODO.md):
|
||||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
- Command injection: GPG key IDs passed unescaped to exec.Command
|
||||||
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
||||||
security command (keychainunlocker.go:472-476).
|
security command (keychainunlocker.go:472-476).
|
||||||
- Memory security: age writes an identity's private key out as a
|
- Memory security: KeychainData stores AgePrivKeyPassphrase as a
|
||||||
string in ordinary memory, and the copies it makes on the way stay
|
plain string (keychainunlocker.go:342,393-396); age identity
|
||||||
there (`secret.IdentityToLockedBuffer` overwrites only the string
|
.String() creates unprotected copies (keychainunlocker.go:356,
|
||||||
itself); private keys exposed via buffer.Bytes() to GPGEncryptFunc
|
pgpunlocker.go:256, version.go:155); age secret key held in a
|
||||||
and EncryptWithPassphrase.
|
plain string in cli/crypto.go:86,91,113; private keys exposed via
|
||||||
- Input validation: no maximum secret size (DoS).
|
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:
|
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
||||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
209-216); non-constant-time public key compare (vault.go:95-100).
|
||||||
- High priority:
|
- High priority:
|
||||||
|
- Return errors instead of panicking on corrupted metadata
|
||||||
|
(pgpunlocker.go:116, keychainunlocker.go:141).
|
||||||
- Secure temporary file handling and cleanup.
|
- Secure temporary file handling and cleanup.
|
||||||
|
- Print cobra usage only for argument errors, not internal
|
||||||
|
failures.
|
||||||
- Initialize a default unlock key at vault creation.
|
- Initialize a default unlock key at vault creation.
|
||||||
|
- Confirmation prompts for destructive operations (keys rm, vault
|
||||||
|
deletion).
|
||||||
- Add secret rm and vault deletion commands.
|
- Add secret rm and vault deletion commands.
|
||||||
- Medium priority:
|
- Medium priority:
|
||||||
- Standardize error messages; stop leaking internals.
|
- Standardize error messages; stop leaking internals.
|
||||||
@@ -373,6 +92,8 @@ Bring the repo into policy compliance in one commit:
|
|||||||
suggestions.
|
suggestions.
|
||||||
- Validate GPG key existence before creating PGP unlock keys.
|
- Validate GPG key existence before creating PGP unlock keys.
|
||||||
- Split oversized CLI functions.
|
- Split oversized CLI functions.
|
||||||
|
- Document env var security (SB_UNLOCK_PASSPHRASE,
|
||||||
|
SB_SECRET_MNEMONIC); clear after use.
|
||||||
- mlock/munlock for sensitive allocations.
|
- mlock/munlock for sensitive allocations.
|
||||||
- Cleanups: read statedir from environment or default instead of
|
- Cleanups: read statedir from environment or default instead of
|
||||||
passing it around.
|
passing it around.
|
||||||
|
|||||||
+2
-6
@@ -1,12 +1,8 @@
|
|||||||
// Package main is the entry point for the secret CLI application.
|
// Package main is the entry point for the secret CLI application.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "git.eeqj.de/sneak/secret/internal/cli"
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(cli.Entry())
|
cli.Entry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ require (
|
|||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||||
github.com/creack/pty v1.1.24
|
|
||||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.14.0
|
||||||
@@ -17,7 +16,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.8.4
|
||||||
github.com/tyler-smith/go-bip39 v1.1.0
|
github.com/tyler-smith/go-bip39 v1.1.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
golang.org/x/sys v0.33.0
|
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +31,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
|
|||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
|
||||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|||||||
+5
-55
@@ -3,11 +3,8 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -17,21 +14,11 @@ type Instance struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
|
|
||||||
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
|
|
||||||
// none.
|
|
||||||
Mnemonic *memguard.LockedBuffer
|
|
||||||
UnlockPassphrase *memguard.LockedBuffer
|
|
||||||
// terminal, when set, stands in for the terminal that confirm reads
|
|
||||||
// the user's answer from; only tests set it. When it is nil, confirm
|
|
||||||
// reads stdin, and only when stdin is a terminal.
|
|
||||||
terminal io.Reader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||||
func NewCLIInstance() (*Instance, error) {
|
func NewCLIInstance() (*Instance, error) {
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
stateDir, err := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||||
@@ -43,8 +30,7 @@ func NewCLIInstance() (*Instance, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithFs creates a new CLI instance with the given
|
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
||||||
// filesystem (for testing)
|
|
||||||
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||||
stateDir, err := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,8 +43,7 @@ func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state
|
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
||||||
// directory (for testing)
|
|
||||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||||
return &Instance{
|
return &Instance{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
@@ -81,42 +66,7 @@ func (cli *Instance) GetStateDir() string {
|
|||||||
return cli.stateDir
|
return cli.stateDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
|
// Print outputs to the command's configured output writer
|
||||||
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
|
func (cli *Instance) Print(a ...interface{}) (n int, err error) {
|
||||||
// either calls it once, before anything else, and passes the buffers on
|
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||||
// from there: each variable is unset as soon as it is read, so that the
|
|
||||||
// processes this one starts, gpg among them, do not inherit it, and a
|
|
||||||
// second read would find nothing. The returned function destroys both
|
|
||||||
// buffers.
|
|
||||||
func (cli *Instance) readSecretEnv() func() {
|
|
||||||
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
|
|
||||||
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
|
|
||||||
|
|
||||||
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
if mnemonic != nil {
|
|
||||||
mnemonic.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if passphrase != nil {
|
|
||||||
passphrase.Destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAndUnsetEnv returns the value of the environment variable name in a
|
|
||||||
// locked buffer, or nil when it is unset or empty, and unsets the variable.
|
|
||||||
// Unsetting does not erase the value: it stays in this process's memory,
|
|
||||||
// and in /proc/<pid>/environ, which shows the environment the process
|
|
||||||
// started with. The caller must destroy the returned buffer.
|
|
||||||
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
|
|
||||||
value := os.Getenv(name)
|
|
||||||
_ = os.Unsetenv(name)
|
|
||||||
|
|
||||||
if value == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes([]byte(value))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
package cli_test
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLIInstanceStateDir(t *testing.T) {
|
func TestCLIInstanceStateDir(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Test the CLI instance state directory functionality
|
// Test the CLI instance state directory functionality
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create a test state directory
|
// Create a test state directory
|
||||||
testStateDir := "/test-state-dir"
|
testStateDir := "/test-state-dir"
|
||||||
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
got := instance.GetStateDir()
|
if cli.GetStateDir() != testStateDir {
|
||||||
if got != testStateDir {
|
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
|
||||||
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // reads process environment to determine the state dir
|
|
||||||
func TestCLIInstanceWithFs(t *testing.T) {
|
func TestCLIInstanceWithFs(t *testing.T) {
|
||||||
// Test creating CLI instance with custom filesystem
|
// Test creating CLI instance with custom filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
cli, err := NewCLIInstanceWithFs(fs)
|
||||||
instance, err := cli.NewCLIInstanceWithFs(fs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The state directory should be determined automatically
|
// The state directory should be determined automatically
|
||||||
stateDir := instance.GetStateDir()
|
stateDir := cli.GetStateDir()
|
||||||
if stateDir == "" {
|
if stateDir == "" {
|
||||||
t.Error("Expected non-empty state directory")
|
t.Error("Expected non-empty state directory")
|
||||||
}
|
}
|
||||||
@@ -54,7 +48,6 @@ func TestDetermineStateDir(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stateDir != testEnvDir {
|
if stateDir != testEnvDir {
|
||||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||||
}
|
}
|
||||||
@@ -62,15 +55,12 @@ func TestDetermineStateDir(t *testing.T) {
|
|||||||
// Test with custom config dir
|
// Test with custom config dir
|
||||||
_ = os.Unsetenv(secret.EnvStateDir)
|
_ = os.Unsetenv(secret.EnvStateDir)
|
||||||
customConfigDir := "/custom-config"
|
customConfigDir := "/custom-config"
|
||||||
|
|
||||||
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||||
if stateDir != expectedDir {
|
if stateDir != expectedDir {
|
||||||
t.Errorf("Expected state directory %q with custom config, got %q",
|
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
|
||||||
expectedDir, stateDir)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnsupportedShell is returned for unknown shell completion targets
|
|
||||||
var errUnsupportedShell = errors.New("unsupported shell type")
|
|
||||||
|
|
||||||
func newCompletionCmd() *cobra.Command {
|
func newCompletionCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
@@ -59,7 +55,7 @@ PowerShell:
|
|||||||
case "powershell":
|
case "powershell":
|
||||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
|
return fmt.Errorf("unsupported shell type: %s", args[0])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-70
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,14 +11,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getSecretNamesCompletionFunc returns a completion function that provides
|
// getSecretNamesCompletionFunc returns a completion function that provides secret names
|
||||||
// secret names
|
|
||||||
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
_ *cobra.Command, _ []string, toComplete string,
|
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,7 +30,6 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
|
|
||||||
// Filter secrets based on what user has typed
|
// Filter secrets based on what user has typed
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
for _, secret := range secrets {
|
for _, secret := range secrets {
|
||||||
if strings.HasPrefix(secret, toComplete) {
|
if strings.HasPrefix(secret, toComplete) {
|
||||||
completions = append(completions, secret)
|
completions = append(completions, secret)
|
||||||
@@ -43,14 +40,11 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
|
||||||
// unlocker IDs
|
|
||||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
_ *cobra.Command, _ []string, toComplete string,
|
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,46 +66,79 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
// Collect unlocker IDs
|
// Collect unlocker IDs
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, metadata := range unlockerMetadataList {
|
||||||
// Get the actual unlocker ID by creating the unlocker instance
|
// Get the actual unlocker ID by creating the unlocker instance
|
||||||
id, err := findUnlockerIDByMetadata(
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
fs, unlockersDir, metadata, false,
|
files, err := afero.ReadDir(fs, unlockersDir)
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn(
|
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
||||||
"Could not read unlockers directory during completion, "+
|
|
||||||
"skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if id != "" && strings.HasPrefix(id, toComplete) {
|
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)
|
completions = append(completions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVaultNamesCompletionFunc returns a completion function that provides
|
// getVaultNamesCompletionFunc returns a completion function that provides vault names
|
||||||
// vault names
|
|
||||||
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
_ *cobra.Command, _ []string, toComplete string,
|
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
|
||||||
vaults, err := vault.ListVaults(fs, stateDir)
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
for _, v := range vaults {
|
for _, v := range vaults {
|
||||||
if strings.HasPrefix(v, toComplete) {
|
if strings.HasPrefix(v, toComplete) {
|
||||||
completions = append(completions, v)
|
completions = append(completions, v)
|
||||||
@@ -122,26 +149,23 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
|
||||||
// colon is present in the input. It completes nothing when the vault part
|
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
|
||||||
// is not a valid vault name, so that a name such as ".." cannot list a
|
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
// directory outside vaults.d.
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
func completeVaultQualifiedSecrets(
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
fs afero.Fs, stateDir, toComplete string,
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
) []string {
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
|
// Check if we're completing after a vault: prefix
|
||||||
|
if strings.Contains(toComplete, ":") {
|
||||||
// Complete secret names for the specified vault
|
// Complete secret names for the specified vault
|
||||||
|
const vaultSecretParts = 2
|
||||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||||
vaultName := parts[0]
|
vaultName := parts[0]
|
||||||
secretPrefix := parts[1]
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
if vault.ValidateVaultName(vaultName) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
|
||||||
secrets, err := vlt.ListSecrets()
|
secrets, err := vlt.ListSecrets()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, secretName := range secrets {
|
for _, secretName := range secrets {
|
||||||
@@ -151,15 +175,8 @@ func completeVaultQualifiedSecrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return completions
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Complete vault names with ":" suffix
|
||||||
vaults, err := vault.ListVaults(fs, stateDir)
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
@@ -172,8 +189,7 @@ func completeUnqualifiedVaultSecrets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also complete secrets from current vault (for within-vault moves)
|
// Also complete secrets from current vault (for within-vault moves)
|
||||||
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
|
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
|
||||||
if err == nil {
|
|
||||||
secrets, err := currentVlt.ListSecrets()
|
secrets, err := currentVlt.ListSecrets()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, secretName := range secrets {
|
for _, secretName := range secrets {
|
||||||
@@ -184,25 +200,6 @@ func completeUnqualifiedVaultSecrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return completions
|
return completions, cobra.ShellCompDirectiveNoSpace
|
||||||
}
|
|
||||||
|
|
||||||
// getVaultSecretCompletionFunc returns a completion function for the
|
|
||||||
// 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) {
|
|
||||||
// Check if we're completing after a vault: prefix
|
|
||||||
if strings.Contains(toComplete, ":") {
|
|
||||||
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
|
||||||
cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
|
|
||||||
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
|
|
||||||
cobra.ShellCompDirectiveNoSpace
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
|
|
||||||
// argument lists nothing when the vault part is not a valid vault name, even
|
|
||||||
// where that name, joined onto vaults.d, leads to a secrets.d directory.
|
|
||||||
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
stateDir = "/state"
|
|
||||||
dirPerm = 0o700
|
|
||||||
)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
// The vault "work" holds the secret "x". So does every directory an
|
|
||||||
// invalid name below would lead to from vaults.d.
|
|
||||||
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
|
|
||||||
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
|
|
||||||
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"work:x"},
|
|
||||||
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
|
|
||||||
|
|
||||||
for _, toComplete := range []string{".:", "..:", "a/b:"} {
|
|
||||||
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
|
||||||
"completing %q", toComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sentinel errors for asking the user to confirm a removal
|
|
||||||
var (
|
|
||||||
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
|
|
||||||
"nobody to ask for confirmation; pass --force to remove without asking")
|
|
||||||
errNotConfirmed = errors.New("cancelled; nothing was removed")
|
|
||||||
errChangedWhileAsking = errors.New("what was to be removed changed " +
|
|
||||||
"while waiting for the answer; nothing was removed")
|
|
||||||
)
|
|
||||||
|
|
||||||
// askThenLock asks the user to confirm a removal, unless force is set, and
|
|
||||||
// then takes the state directory lock and returns the function that
|
|
||||||
// releases it. find makes the command's checks, keeps what it found for
|
|
||||||
// the caller to remove, and returns the question that names it. find runs
|
|
||||||
// before the question, which is asked without the lock so that no other
|
|
||||||
// command waits while the user answers, and runs again once the lock is
|
|
||||||
// taken. That run is the last, so the caller removes what find found under
|
|
||||||
// the lock. If its question then differs from the one the user answered,
|
|
||||||
// something changed in between, and askThenLock fails.
|
|
||||||
func (cli *Instance) askThenLock(
|
|
||||||
cmd *cobra.Command, force bool, find func() (string, error),
|
|
||||||
) (func(), error) {
|
|
||||||
asked := ""
|
|
||||||
|
|
||||||
if !force {
|
|
||||||
question, err := find()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cli.confirm(cmd, question)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
asked = question
|
|
||||||
}
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
question, err := find()
|
|
||||||
if err == nil && !force && question != asked {
|
|
||||||
err = errChangedWhileAsking
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
release()
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return release, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// confirm asks question and returns nil only when the user answers y or
|
|
||||||
// yes; any other answer, a bare Enter included, cancels. When stdin is not
|
|
||||||
// a terminal it asks nothing and fails at once: nobody is there to answer,
|
|
||||||
// and waiting for an answer would hang a script. Stdin decides, not
|
|
||||||
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
|
|
||||||
// still asks. The question goes to stderr.
|
|
||||||
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
|
|
||||||
answers := cli.terminal
|
|
||||||
if answers == nil {
|
|
||||||
answers = cmd.InOrStdin()
|
|
||||||
|
|
||||||
if !isTerminal(answers) {
|
|
||||||
return errNoTerminal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
|
|
||||||
|
|
||||||
answer, err := bufio.NewReader(answers).ReadString('\n')
|
|
||||||
if err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
return fmt.Errorf("failed to read the answer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(answer)) {
|
|
||||||
case "y", "yes":
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return errNotConfirmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTerminal reports whether r is a terminal.
|
|
||||||
func isTerminal(r io.Reader) bool {
|
|
||||||
file, ok := r.(*os.File)
|
|
||||||
|
|
||||||
return ok && term.IsTerminal(int(file.Fd()))
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
// Confirmation Tests
|
|
||||||
//
|
|
||||||
// `secret rm`, `secret version rm`, `secret vault remove` and
|
|
||||||
// `secret unlocker remove` ask the user to confirm on a terminal, naming
|
|
||||||
// what they are about to remove, and remove it only on y or yes. --force
|
|
||||||
// skips the question. Without --force, a command whose stdin is not a
|
|
||||||
// terminal fails at once, since nobody is there to answer.
|
|
||||||
//
|
|
||||||
// The tests answer through Instance.terminal, which stands in for a
|
|
||||||
// terminal. Without it, whether stdin is a terminal decides; the tests in
|
|
||||||
// integration_test.go that run `secret rm` on a pseudo-terminal cover that.
|
|
||||||
|
|
||||||
//nolint:testpackage // sets the unexported terminal field of Instance
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// confirmTestSecret is the secret the tests remove, or remove a
|
|
||||||
// version of, in the vault "work".
|
|
||||||
confirmTestSecret = "test/secret"
|
|
||||||
|
|
||||||
// lastUnlockerRemoval names the case that removes the only unlocker.
|
|
||||||
lastUnlockerRemoval = "unlocker rm, the last one"
|
|
||||||
)
|
|
||||||
|
|
||||||
// removal is one removal command, set up on its own state directory.
|
|
||||||
type removal struct {
|
|
||||||
fs afero.Fs
|
|
||||||
run func(cli *Instance, cmd *cobra.Command, force bool) error
|
|
||||||
// removed is the directory the command removes.
|
|
||||||
removed string
|
|
||||||
// question is the question the command asks.
|
|
||||||
question string
|
|
||||||
}
|
|
||||||
|
|
||||||
// newConfirmTestVaults returns an in-memory state directory with the
|
|
||||||
// vaults "other" and "work", the current one. "work" holds two versions of
|
|
||||||
// confirmTestSecret and the given number of PGP unlockers. It returns the
|
|
||||||
// directory of "work" and the older version.
|
|
||||||
func newConfirmTestVaults(
|
|
||||||
t *testing.T, unlockers int,
|
|
||||||
) (*afero.MemMapFs, string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := &afero.MemMapFs{}
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
|
||||||
addTestSecret(t, vlt, []byte("newer"), true)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 2)
|
|
||||||
|
|
||||||
for i := range unlockers {
|
|
||||||
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
|
|
||||||
fmt.Sprintf("pgp-%d", i),
|
|
||||||
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
|
|
||||||
listTestGPGKeyID+string(rune('A'+i)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListVersions lists the newest version first.
|
|
||||||
return fs, vaultDir, versions[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRemoval sets up the removal the command names.
|
|
||||||
func newRemoval(t *testing.T, command string) removal {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
unlockers := 2
|
|
||||||
if command == lastUnlockerRemoval {
|
|
||||||
unlockers = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
|
||||||
unlockerID := "pgp-" + listTestGPGKeyID + "A"
|
|
||||||
|
|
||||||
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
|
||||||
return cli.UnlockersRemove(unlockerID, force, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch command {
|
|
||||||
case "rm":
|
|
||||||
return removal{
|
|
||||||
fs: fs,
|
|
||||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
|
||||||
return cli.RemoveSecret(cmd, confirmTestSecret, force)
|
|
||||||
},
|
|
||||||
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
|
|
||||||
question: "Permanently remove secret 'test/secret' and its 2 " +
|
|
||||||
"version(s) from vault 'work'?",
|
|
||||||
}
|
|
||||||
case "version rm":
|
|
||||||
return removal{
|
|
||||||
fs: fs,
|
|
||||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
|
||||||
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
|
|
||||||
},
|
|
||||||
removed: filepath.Join(
|
|
||||||
workDir, "secrets.d", "test%secret", "versions", older),
|
|
||||||
question: "Permanently remove version " + older +
|
|
||||||
" of secret 'test/secret' from vault 'work'?",
|
|
||||||
}
|
|
||||||
case "vault rm":
|
|
||||||
return removal{
|
|
||||||
fs: fs,
|
|
||||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
|
||||||
return cli.RemoveVault(cmd, "work", force)
|
|
||||||
},
|
|
||||||
removed: workDir,
|
|
||||||
question: "Permanently remove vault 'work' and its 1 secret(s)?",
|
|
||||||
}
|
|
||||||
case "unlocker rm":
|
|
||||||
return removal{
|
|
||||||
fs: fs,
|
|
||||||
run: removeFirstUnlocker,
|
|
||||||
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
|
||||||
"' from vault 'work'? It is not the vault's last unlocker.",
|
|
||||||
}
|
|
||||||
case lastUnlockerRemoval:
|
|
||||||
return removal{
|
|
||||||
fs: fs,
|
|
||||||
run: removeFirstUnlocker,
|
|
||||||
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
|
||||||
"', the last unlocker of vault 'work', which holds 1 " +
|
|
||||||
"secret(s)? Without an unlocker the vault opens only " +
|
|
||||||
"with its mnemonic.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("no removal %q", command)
|
|
||||||
|
|
||||||
return removal{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removalCommands lists the commands newRemoval sets up.
|
|
||||||
func removalCommands() []string {
|
|
||||||
return []string{
|
|
||||||
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newConfirmTestCommand returns a command whose output is discarded and
|
|
||||||
// whose stderr, where the question goes, is the returned buffer.
|
|
||||||
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
cmd.SetErr(&stderr)
|
|
||||||
|
|
||||||
return cmd, &stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExists asserts whether the directory dir exists.
|
|
||||||
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, want, exists, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
|
|
||||||
// any case, around which spaces do not matter.
|
|
||||||
func TestConfirmAnswers(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for answer, want := range map[string]error{
|
|
||||||
"y\n": nil,
|
|
||||||
"Y\n": nil,
|
|
||||||
"yes\n": nil,
|
|
||||||
" YES \n": nil,
|
|
||||||
"y": nil,
|
|
||||||
"\n": errNotConfirmed,
|
|
||||||
"": errNotConfirmed,
|
|
||||||
"n\n": errNotConfirmed,
|
|
||||||
"yy\n": errNotConfirmed,
|
|
||||||
"no\ny\n": errNotConfirmed,
|
|
||||||
} {
|
|
||||||
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli := &Instance{terminal: strings.NewReader(answer)}
|
|
||||||
cmd, stderr := newConfirmTestCommand()
|
|
||||||
|
|
||||||
err := cli.confirm(cmd, "Remove it?")
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, want)
|
|
||||||
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
|
|
||||||
// and removes what it names when the user answers y.
|
|
||||||
func TestRemovalAnsweredYesRemoves(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, command := range removalCommands() {
|
|
||||||
t.Run(command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newRemoval(t, command)
|
|
||||||
requireExists(t, r.fs, r.removed, true)
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
cli.terminal = strings.NewReader("y\n")
|
|
||||||
cmd, stderr := newConfirmTestCommand()
|
|
||||||
|
|
||||||
require.NoError(t, r.run(cli, cmd, false))
|
|
||||||
|
|
||||||
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
|
||||||
requireExists(t, r.fs, r.removed, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovalDeclinedLeavesEverything checks that each removal changes
|
|
||||||
// nothing when the user answers anything but y or yes, a bare Enter
|
|
||||||
// included.
|
|
||||||
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, command := range removalCommands() {
|
|
||||||
for _, answer := range []string{"\n", "n\n", ""} {
|
|
||||||
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newRemoval(t, command)
|
|
||||||
before := stateDirModTimes(t, r.fs)
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
cli.terminal = strings.NewReader(answer)
|
|
||||||
cmd, stderr := newConfirmTestCommand()
|
|
||||||
|
|
||||||
err := r.run(cli, cmd, false)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errNotConfirmed)
|
|
||||||
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
|
||||||
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovalForcedAsksNothing checks that each removal with --force
|
|
||||||
// removes what it would have named without asking, and without reading
|
|
||||||
// its input, which is not a terminal.
|
|
||||||
func TestRemovalForcedAsksNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, command := range removalCommands() {
|
|
||||||
t.Run(command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newRemoval(t, command)
|
|
||||||
|
|
||||||
input := strings.NewReader("n\n")
|
|
||||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
cmd, stderr := newConfirmTestCommand()
|
|
||||||
cmd.SetIn(input)
|
|
||||||
|
|
||||||
require.NoError(t, r.run(cli, cmd, true))
|
|
||||||
|
|
||||||
assert.Empty(t, stderr.String(), "asked with --force")
|
|
||||||
assert.Equal(t, 2, input.Len(), "read its input with --force")
|
|
||||||
requireExists(t, r.fs, r.removed, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
|
|
||||||
// --force, whose input is not a terminal, fails at once telling the user
|
|
||||||
// to pass --force, and changes nothing. The input is a pipe that nobody
|
|
||||||
// writes to or closes, so reading it would block for good.
|
|
||||||
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, command := range removalCommands() {
|
|
||||||
t.Run(command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newRemoval(t, command)
|
|
||||||
before := stateDirModTimes(t, r.fs)
|
|
||||||
|
|
||||||
input, inputWriter, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = inputWriter.Close()
|
|
||||||
_ = input.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
cmd, stderr := newConfirmTestCommand()
|
|
||||||
cmd.SetIn(input)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- r.run(cli, cmd, false) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.ErrorIs(t, err, errNoTerminal)
|
|
||||||
assert.Contains(t, err.Error(), "pass --force")
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
// Closing the pipe ends the read, and frees the lock if
|
|
||||||
// the command holds it.
|
|
||||||
_ = inputWriter.Close()
|
|
||||||
|
|
||||||
t.Fatal("waited for an answer on input that is not a terminal")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Empty(t, stderr.String(), "asked without a terminal")
|
|
||||||
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
|
|
||||||
// for its answer, another command can take the state directory lock and
|
|
||||||
// change the secret, and that the removal then removes nothing, since the
|
|
||||||
// secret is no longer what the question named.
|
|
||||||
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newRemoval(t, "rm")
|
|
||||||
|
|
||||||
answers, answerWriter := io.Pipe()
|
|
||||||
questions, questionWriter := io.Pipe()
|
|
||||||
|
|
||||||
// Closing the answers ends the read if the test fails while the
|
|
||||||
// command waits for one.
|
|
||||||
t.Cleanup(func() { _ = answerWriter.Close() })
|
|
||||||
|
|
||||||
rm := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
rm.terminal = answers
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
cmd.SetErr(questionWriter)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- r.run(rm, cmd, false) }()
|
|
||||||
|
|
||||||
question, err := bufio.NewReader(questions).ReadString(']')
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, r.question+" [y/N]", question)
|
|
||||||
|
|
||||||
// Adds a third version while rm waits for its answer.
|
|
||||||
add := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
|
||||||
add.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
add.cmd = &cobra.Command{}
|
|
||||||
add.cmd.SetIn(strings.NewReader("newest"))
|
|
||||||
add.cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
added := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { added <- add.AddSecret(confirmTestSecret, true) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-added:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("secret add waited for the lock while secret rm asked")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = answerWriter.Write([]byte("y\n"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.ErrorIs(t, err, errChangedWhileAsking)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("secret rm did not finish once answered")
|
|
||||||
}
|
|
||||||
|
|
||||||
requireExists(t, r.fs, r.removed, true)
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCreateExistingVaultChangesNothing is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
|
|
||||||
// a second time, or `secret vault create` with the name of an existing
|
|
||||||
// vault, replaced that vault's keys, so that none of its secrets could be
|
|
||||||
// decrypted any more. Each must refuse, change nothing, and leave every
|
|
||||||
// vault's secret readable through its passphrase unlocker.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
// newCLI returns an instance on fs given the mnemonic and the unlock
|
|
||||||
// passphrase, as from the environment
|
|
||||||
newCLI := func(fs afero.Fs) *cli.Instance {
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
c.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// `secret init`, `secret vault create work`, `secret vault select
|
|
||||||
// default`, and the secret "x" in each vault. "work" is then not the
|
|
||||||
// current vault, which creating it again must not change.
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
c := newCLI(fs)
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
require.NoError(t, c.Init(cmd))
|
|
||||||
require.NoError(t, c.CreateVault(cmd, "work"))
|
|
||||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
|
||||||
|
|
||||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, vaults, 2)
|
|
||||||
|
|
||||||
for _, name := range vaults {
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
||||||
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, fs)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
want string
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"init",
|
|
||||||
"failed to create default vault: vault default already exists",
|
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create default",
|
|
||||||
"vault default already exists",
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create work",
|
|
||||||
"vault work already exists",
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
|
|
||||||
err := tt.run(newCLI(fs))
|
|
||||||
|
|
||||||
require.EqualError(t, err, tt.want)
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every case left the state directory exactly as recorded in before, so
|
|
||||||
// reading each vault's secret once from it shows that it still decrypts
|
|
||||||
// after each case. Without the mnemonic, reading a secret goes through
|
|
||||||
// the vault's passphrase unlocker, which is slow.
|
|
||||||
for _, name := range vaults {
|
|
||||||
vlt := vault.NewVault(fs, testStateDir, name)
|
|
||||||
vlt.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
unchanged := bytes.Equal([]byte("value"), value.Bytes())
|
|
||||||
value.Destroy()
|
|
||||||
|
|
||||||
require.True(t, unchanged, "vault %q kept its secret", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
|
|
||||||
// `secret vault create` put the mnemonic into the process environment,
|
|
||||||
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
|
|
||||||
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
|
|
||||||
// leave neither in the environment.
|
|
||||||
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
|
||||||
t.Setenv(secret.EnvStateDir, t.TempDir())
|
|
||||||
|
|
||||||
run := func(args ...string) {
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
// With no terminal to prompt on, this succeeds only if the command
|
|
||||||
// read both variables
|
|
||||||
_, err := cli.ExecuteCommandInProcess(args, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
|
|
||||||
_, set := os.LookupEnv(name)
|
|
||||||
require.False(t, set, "%s is set after %v", name, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run("init")
|
|
||||||
run("vault", "create", "work")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
|
||||||
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
|
||||||
// `secret vault create` stopped at the passphrase prompt left a vault with
|
|
||||||
// no unlocker, which neither command would then create again. Each must ask
|
|
||||||
// for the passphrase before writing anything.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// An empty state directory for `secret init`, and one holding the vault
|
|
||||||
// "default" for `secret vault create work`.
|
|
||||||
empty := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
|
||||||
|
|
||||||
withDefault := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
fs afero.Fs
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"init",
|
|
||||||
empty,
|
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create work",
|
|
||||||
withDefault,
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, tt.fs)
|
|
||||||
|
|
||||||
// Given no unlock passphrase, both commands prompt for it, which
|
|
||||||
// fails because the tests do not run in a terminal.
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
err := tt.run(c)
|
|
||||||
|
|
||||||
require.ErrorContains(t, err, "failed to read passphrase")
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, tt.fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+87
-149
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,22 +12,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for encrypt/decrypt operations
|
func newEncryptCmd() *cobra.Command {
|
||||||
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 {
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: use,
|
Use: "encrypt <secret-name>",
|
||||||
Short: short,
|
Short: "Encrypt data using an age secret key stored in a secret",
|
||||||
Long: long,
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
inputFile, _ := cmd.Flags().GetString("input")
|
inputFile, _ := cmd.Flags().GetString("input")
|
||||||
@@ -38,13 +26,9 @@ func newCryptoCmd(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return run(cli, args[0], inputFile, outputFile)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,121 +38,89 @@ func newCryptoCmd(
|
|||||||
return cmd
|
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 {
|
func newDecryptCmd() *cobra.Command {
|
||||||
return newCryptoCmd(
|
cmd := &cobra.Command{
|
||||||
"decrypt <secret-name>",
|
Use: "decrypt <secret-name>",
|
||||||
"Decrypt data using an age secret key stored in a secret",
|
Short: "Decrypt data using an age secret key stored in a secret",
|
||||||
"Decrypt data using an age secret key stored in the specified secret.",
|
Long: `Decrypt data using an age secret key stored in the specified secret.`,
|
||||||
(*Instance).Decrypt,
|
Args: cobra.ExactArgs(1),
|
||||||
)
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
}
|
inputFile, _ := cmd.Flags().GetString("input")
|
||||||
|
outputFile, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
// storeNewEncryptionKey generates an age secret key and stores it as the
|
cli, err := NewCLIInstance()
|
||||||
// named secret, holding the state directory lock while it does. It fails
|
|
||||||
// with vault.ErrSecretExists if another command stored the secret first.
|
|
||||||
// The caller must destroy the returned buffer.
|
|
||||||
func (cli *Instance) storeNewEncryptionKey(
|
|
||||||
vlt *vault.Vault, secretName string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
defer release()
|
cli.cmd = cmd
|
||||||
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||||
if err != nil {
|
},
|
||||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secureBuffer := secret.IdentityToLockedBuffer(identity)
|
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||||
|
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
return cmd
|
||||||
if err != nil {
|
|
||||||
secureBuffer.Destroy()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to store age key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return secureBuffer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. Only storing
|
|
||||||
// a new key takes the state directory lock, so that reading an existing key
|
|
||||||
// works on a read-only state directory and keeps no other command waiting
|
|
||||||
// at the passphrase prompt, and Encrypt streams its input and output
|
|
||||||
// unlocked.
|
|
||||||
func (cli *Instance) resolveEncryptionKey(
|
|
||||||
vlt *vault.Vault, secretName string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
// Check if secret exists
|
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
|
||||||
|
|
||||||
exists, err := secretObj.Exists()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
key, err := cli.storeNewEncryptionKey(vlt, secretName)
|
|
||||||
if !errors.Is(err, vault.ErrSecretExists) {
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
// Another command stored the key since the check above: read it
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt encrypts data using an age secret key stored in a secret
|
// Encrypt encrypts data using an age secret key stored in a secret
|
||||||
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||||
err := vault.ValidateSecretName(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
var ageSecretKey string
|
||||||
|
|
||||||
// Get or create the age secret key for this secret
|
// Check if secret exists
|
||||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
exists, err := secretObj.Exists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
defer keyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Parse the secret key
|
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
||||||
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -178,27 +130,23 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Set up input reader
|
// Set up input reader
|
||||||
var input io.Reader = os.Stdin
|
var input io.Reader = os.Stdin
|
||||||
|
|
||||||
if inputFile != "" {
|
if inputFile != "" {
|
||||||
file, err := cli.fs.Open(inputFile)
|
file, err := cli.fs.Open(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,13 +156,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(encryptor, input)
|
if _, err := io.Copy(encryptor, input); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = encryptor.Close()
|
if err := encryptor.Close(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,33 +169,34 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Decrypt decrypts data using an age secret key stored in a secret
|
// Decrypt decrypts data using an age secret key stored in a secret
|
||||||
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||||
err := vault.ValidateSecretName(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
|
||||||
exists, err := secretObj.Exists()
|
exists, err := secretObj.Exists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
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
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -257,7 +204,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
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
|
// Parse the age secret key to get the identity
|
||||||
@@ -268,27 +215,23 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Set up input reader
|
// Set up input reader
|
||||||
var input io.Reader = os.Stdin
|
var input io.Reader = os.Stdin
|
||||||
|
|
||||||
if inputFile != "" {
|
if inputFile != "" {
|
||||||
file, err := cli.fs.Open(inputFile)
|
file, err := cli.fs.Open(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,29 +241,24 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(output, decryptor)
|
if _, err := io.Copy(output, decryptor); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
||||||
// attempting to parse it
|
|
||||||
func isValidAgeSecretKey(key string) bool {
|
func isValidAgeSecretKey(key string) bool {
|
||||||
_, err := age.ParseX25519Identity(key)
|
_, err := age.ParseX25519Identity(key)
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret with the vault's mnemonic
|
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||||
// when it has one, else with the current unlocker
|
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
||||||
func (cli *Instance) getSecretValue(
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
vlt *vault.Vault, secretObj *secret.Secret,
|
return secretObj.GetValue(nil)
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
if vlt.Mnemonic != nil {
|
|
||||||
return secretObj.GetValue(nil, vlt.Mnemonic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker, err := vlt.GetCurrentUnlocker()
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
@@ -328,5 +266,5 @@ func (cli *Instance) getSecretValue(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return secretObj.GetValue(unlocker, nil)
|
return secretObj.GetValue(unlocker)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entry must return its exit code rather than exit, so that its deferred
|
|
||||||
// memguard purge runs on the success and the error path alike.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
|
|
||||||
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
|
|
||||||
savedArgs := os.Args
|
|
||||||
|
|
||||||
t.Cleanup(func() { os.Args = savedArgs })
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
args []string
|
|
||||||
exitCode int
|
|
||||||
}{
|
|
||||||
{args: []string{"secret", "--help"}, exitCode: 0},
|
|
||||||
{args: []string{"secret", "no-such-command"}, exitCode: 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
buf := memguard.NewBufferFromBytes([]byte("key material"))
|
|
||||||
os.Args = tt.args
|
|
||||||
|
|
||||||
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
|
|
||||||
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl-C while `secret add` waits for the value on stdin must end the
|
|
||||||
// process through memguard's signal handler, which wipes every buffer and
|
|
||||||
// exits with status 1, not through Go's default handling, which kills the
|
|
||||||
// process with the buffers intact.
|
|
||||||
func TestInterruptExitsThroughMemguard(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const waitingForValue = "Reading secret value from stdin"
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
wd, err := filepath.Abs("../..")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
secretPath := filepath.Join(wd, "secret")
|
|
||||||
env := []string{
|
|
||||||
secret.EnvStateDir + "=" + t.TempDir(),
|
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
|
||||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
|
||||||
"PATH=/usr/bin:/bin",
|
|
||||||
// The debug log on stderr shows when add starts waiting for the value.
|
|
||||||
"GODEBUG=berlin.sneak.pkg.secret",
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
||||||
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
|
||||||
initCmd.Env = env
|
|
||||||
|
|
||||||
output, err := initCmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, "init should succeed: %s", output)
|
|
||||||
|
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
||||||
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
|
|
||||||
addCmd.Env = env
|
|
||||||
|
|
||||||
// Held open and never written, so add keeps waiting for the value.
|
|
||||||
stdin, err := addCmd.StdinPipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = stdin.Close() }()
|
|
||||||
|
|
||||||
stderr, err := addCmd.StderrPipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, addCmd.Start())
|
|
||||||
|
|
||||||
waiting := false
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stderr)
|
|
||||||
for !waiting && scanner.Scan() {
|
|
||||||
waiting = strings.Contains(scanner.Text(), waitingForValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.True(t, waiting, "add never logged %q", waitingForValue)
|
|
||||||
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
|
|
||||||
|
|
||||||
err = addCmd.Wait()
|
|
||||||
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &exitErr)
|
|
||||||
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
|
|
||||||
}
|
|
||||||
+13
-46
@@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,16 +17,6 @@ const (
|
|||||||
mnemonicEntropyBits = 128
|
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 {
|
func newGenerateCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "generate",
|
Use: "generate",
|
||||||
@@ -63,8 +52,7 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "secret <name>",
|
Use: "secret <name>",
|
||||||
Short: "Generate a random secret and store it in the vault",
|
Short: "Generate a random secret and store it in the vault",
|
||||||
Long: `Generate a cryptographically secure random secret and ` +
|
Long: `Generate a cryptographically secure random secret and store it in the current vault under the given name.`,
|
||||||
`store it in the current vault under the given name.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
length, _ := cmd.Flags().GetInt("length")
|
length, _ := cmd.Flags().GetInt("length")
|
||||||
@@ -76,17 +64,12 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().IntP("length", "l", defaultSecretLength,
|
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
|
||||||
"Length of the generated secret (default 16)")
|
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
||||||
cmd.Flags().StringP("type", "t", "base58",
|
|
||||||
"Type of secret to generate (base58, alnum)")
|
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -115,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, " • 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, " • 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, " • You will need this phrase to recover your secrets")
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprintln(os.Stderr, " • If you lose this phrase, your secrets cannot be recovered")
|
||||||
" • If you lose this phrase, your secrets cannot be recovered")
|
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||||
@@ -134,13 +116,11 @@ func (cli *Instance) GenerateSecret(
|
|||||||
force bool,
|
force bool,
|
||||||
) error {
|
) error {
|
||||||
if length < 1 {
|
if length < 1 {
|
||||||
return errLengthTooSmall
|
return fmt.Errorf("length must be at least 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var secretValue string
|
||||||
secretValue string
|
var err error
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
switch secretType {
|
switch secretType {
|
||||||
case "base58":
|
case "base58":
|
||||||
@@ -148,41 +128,30 @@ func (cli *Instance) GenerateSecret(
|
|||||||
case "alnum":
|
case "alnum":
|
||||||
secretValue, err = generateRandomAlnum(length)
|
secretValue, err = generateRandomAlnum(length)
|
||||||
case "mnemonic":
|
case "mnemonic":
|
||||||
return errMnemonicTypeNotSupported
|
return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
|
||||||
errUnsupportedSecretType, secretType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate random secret: %w", err)
|
return fmt.Errorf("failed to generate random secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Store the secret in the vault
|
// Store the secret in the vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Protect the generated secret immediately
|
// Protect the generated secret immediately
|
||||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||||
defer secretBuffer.Destroy()
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secretBuffer, force)
|
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n",
|
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
||||||
length, secretType, secretName)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -201,11 +170,10 @@ func generateRandomAlnum(length int) (string, error) {
|
|||||||
return generateRandomString(length, alnumChars)
|
return generateRandomString(length, alnumChars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomString generates a random string of the specified length
|
// generateRandomString generates a random string of the specified length using the given character set
|
||||||
// using the given character set
|
|
||||||
func generateRandomString(length int, charset string) (string, error) {
|
func generateRandomString(length int, charset string) (string, error) {
|
||||||
if length <= 0 {
|
if length <= 0 {
|
||||||
return "", errLengthNotPositive
|
return "", fmt.Errorf("length must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
@@ -216,7 +184,6 @@ func generateRandomString(length int, charset string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = charset[randomIndex.Int64()]
|
result[i] = charset[randomIndex.Int64()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-19
@@ -18,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version info - these are set at build time
|
// Version info - these are set at build time
|
||||||
var (
|
var ( //nolint:gochecknoglobals // Set at build time
|
||||||
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||||
GitCommit = "unknown" //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"`
|
NumVaults int `json:"numVaults"`
|
||||||
NumSecrets int `json:"numSecrets"`
|
NumSecrets int `json:"numSecrets"`
|
||||||
TotalSize int64 `json:"totalSizeBytes"`
|
TotalSize int64 `json:"totalSizeBytes"`
|
||||||
OldestSecret time.Time `json:"oldestSecret"`
|
OldestSecret time.Time `json:"oldestSecret,omitempty"`
|
||||||
LatestSecret time.Time `json:"latestSecret"`
|
LatestSecret time.Time `json:"latestSecret,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// newInfoCmd returns the info command
|
// newInfoCmd returns the info command
|
||||||
@@ -51,8 +51,7 @@ func newInfoCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "info",
|
Use: "info",
|
||||||
Short: "Display system information",
|
Short: "Display system information",
|
||||||
Long: "Display information about the secret system including " +
|
Long: "Display information about the secret system including version, vault statistics, and storage usage",
|
||||||
"version, vault statistics, and storage usage",
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return cli.Info(cmd, jsonOutput)
|
return cli.Info(cmd, jsonOutput)
|
||||||
},
|
},
|
||||||
@@ -82,7 +81,6 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
// Count vaults
|
// Count vaults
|
||||||
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||||
|
|
||||||
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, entry := range vaultEntries {
|
for _, entry := range vaultEntries {
|
||||||
@@ -94,15 +92,12 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
// Gather statistics from all vaults
|
// Gather statistics from all vaults
|
||||||
if info.NumVaults > 0 {
|
if info.NumVaults > 0 {
|
||||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
|
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(cli.fs, vaultsDir)
|
||||||
cli.fs, vaultsDir)
|
|
||||||
info.NumSecrets = totalSecrets
|
info.NumSecrets = totalSecrets
|
||||||
info.TotalSize = totalSize
|
info.TotalSize = totalSize
|
||||||
|
|
||||||
if !oldestTime.IsZero() {
|
if !oldestTime.IsZero() {
|
||||||
info.OldestSecret = oldestTime
|
info.OldestSecret = oldestTime
|
||||||
}
|
}
|
||||||
|
|
||||||
if !latestTime.IsZero() {
|
if !latestTime.IsZero() {
|
||||||
info.LatestSecret = latestTime
|
info.LatestSecret = latestTime
|
||||||
}
|
}
|
||||||
@@ -149,24 +144,19 @@ func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
|||||||
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
||||||
|
|
||||||
if info.TotalSize >= 0 {
|
if info.TotalSize >= 0 {
|
||||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
|
//nolint:gosec // TotalSize is always >= 0
|
||||||
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.OldestSecret.IsZero() {
|
if !info.OldestSecret.IsZero() {
|
||||||
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n",
|
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n", info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||||
info.OldestSecret.Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.LatestSecret.IsZero() {
|
if !info.LatestSecret.IsZero() {
|
||||||
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n",
|
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||||
info.LatestSecret.Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintln(w)
|
_, _ = fmt.Fprintln(w)
|
||||||
|
|||||||
+50
-84
@@ -8,83 +8,30 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vaultStats accumulates statistics while walking vault directories
|
// gatherVaultStats collects statistics from all vaults
|
||||||
type vaultStats struct {
|
func gatherVaultStats(
|
||||||
totalSecrets int
|
fs afero.Fs,
|
||||||
totalSize int64
|
vaultsDir string,
|
||||||
oldestTime time.Time
|
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) {
|
||||||
latestTime time.Time
|
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
secret.Warn("Could not read versions directory for secret",
|
return 0, 0, time.Time{}, time.Time{}, err
|
||||||
"secret", secretName, "error", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, versionEntry := range versionEntries {
|
for _, vaultEntry := range vaultEntries {
|
||||||
if !versionEntry.IsDir() {
|
if !vaultEntry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
|
vaultPath := filepath.Join(vaultsDir, vaultEntry.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")
|
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||||
|
|
||||||
// Count secrets in this vault
|
// Count secrets in this vault
|
||||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read secrets directory for vault",
|
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
|
||||||
"vault", vaultName, "error", err)
|
|
||||||
|
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, secretEntry := range secretEntries {
|
for _, secretEntry := range secretEntries {
|
||||||
@@ -92,31 +39,50 @@ func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s.addSecret(fs, secretsPath, secretEntry.Name())
|
totalSecrets++
|
||||||
}
|
secretPath := filepath.Join(secretsPath, secretEntry.Name())
|
||||||
}
|
|
||||||
|
|
||||||
// gatherVaultStats collects statistics from all vaults, returning the
|
// Get size and timestamps from all versions
|
||||||
// total secret count, total size, and oldest/latest secret timestamps
|
versionsPath := filepath.Join(secretPath, "versions")
|
||||||
func gatherVaultStats(
|
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||||
fs afero.Fs,
|
|
||||||
vaultsDir string,
|
|
||||||
) (int, int64, time.Time, time.Time, error) {
|
|
||||||
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, time.Time{}, time.Time{}, err
|
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
|
||||||
}
|
|
||||||
|
|
||||||
var stats vaultStats
|
|
||||||
|
|
||||||
for _, vaultEntry := range vaultEntries {
|
|
||||||
if !vaultEntry.IsDir() {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.addVault(fs, vaultsDir, vaultEntry.Name())
|
for _, versionEntry := range versionEntries {
|
||||||
|
if !versionEntry.IsDir() {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats.totalSecrets, stats.totalSize,
|
versionPath := filepath.Join(versionsPath, versionEntry.Name())
|
||||||
stats.oldestTime, stats.latestTime, nil
|
|
||||||
|
// Add size of encrypted data
|
||||||
|
dataPath := filepath.Join(versionPath, "data.age")
|
||||||
|
if stat, err := fs.Stat(dataPath); err == nil {
|
||||||
|
totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add size of metadata
|
||||||
|
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||||
|
if stat, err := fs.Stat(metaPath); err == nil {
|
||||||
|
totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track timestamps
|
||||||
|
if stat, err := fs.Stat(versionPath); err == nil {
|
||||||
|
modTime := stat.ModTime()
|
||||||
|
if oldestTime.IsZero() || modTime.Before(oldestTime) {
|
||||||
|
oldestTime = modTime
|
||||||
|
}
|
||||||
|
if latestTime.IsZero() || modTime.After(latestTime) {
|
||||||
|
latestTime = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSecrets, totalSize, oldestTime, latestTime, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-110
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
@@ -18,16 +16,12 @@ import (
|
|||||||
"github.com/tyler-smith/go-bip39"
|
"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
|
// NewInitCmd creates the init command
|
||||||
func NewInitCmd() *cobra.Command {
|
func NewInitCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Initialize the secrets manager",
|
Short: "Initialize the secrets manager",
|
||||||
Long: `Create the necessary directory structure for storing ` +
|
Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`,
|
||||||
`secrets and generate encryption keys.`,
|
|
||||||
RunE: RunInit,
|
RunE: RunInit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,97 +33,18 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
|||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.Init(cmd)
|
return cli.Init(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
|
// Init initializes the secret manager
|
||||||
// or reads it interactively. The returned cleanup function must be deferred
|
|
||||||
// by the caller.
|
|
||||||
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|
||||||
if cli.Mnemonic != nil {
|
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
|
||||||
|
|
||||||
return cli.Mnemonic, 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, nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
||||||
|
|
||||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupDefaultVault creates the default vault and derives its long-term
|
|
||||||
// identity from the mnemonic
|
|
||||||
func (cli *Instance) setupDefaultVault(
|
|
||||||
stateDir string, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*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", mnemonic)
|
|
||||||
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(mnemonic.String(), 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, holding the state directory lock
|
|
||||||
// while initialize runs
|
|
||||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.initialize(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize creates the state directory, the default vault and its first
|
|
||||||
// unlocker
|
|
||||||
func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|
||||||
secret.Debug("Starting secret manager initialization")
|
secret.Debug("Starting secret manager initialization")
|
||||||
|
|
||||||
// Create state directory
|
// Create state directory
|
||||||
stateDir := cli.GetStateDir()
|
stateDir := cli.GetStateDir()
|
||||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||||
|
|
||||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create state directory", "error", err)
|
secret.Debug("Failed to create state directory", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create state directory: %w", err)
|
return fmt.Errorf("failed to create state directory: %w", err)
|
||||||
@@ -140,53 +55,100 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for mnemonic
|
// Prompt for mnemonic
|
||||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
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 {
|
if err != nil {
|
||||||
return err
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
defer cleanupMnemonic()
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
|
}
|
||||||
|
|
||||||
mnemonicStr := mnemonic.String()
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
secret.Debug("Empty mnemonic provided")
|
secret.Debug("Empty mnemonic provided")
|
||||||
|
|
||||||
return errMnemonicEmpty
|
return fmt.Errorf("mnemonic cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the mnemonic using BIP39
|
// Validate the mnemonic using BIP39
|
||||||
secret.DebugWith("Validating BIP39 mnemonic",
|
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||||
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||||
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
|
||||||
errInvalidMnemonicPhrase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for the unlocker passphrase before creating the vault, so that
|
// Set mnemonic in environment for CreateVault to use
|
||||||
// stopping at the prompt leaves no vault without an unlocker behind
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
|
defer func() {
|
||||||
|
if originalMnemonic != "" {
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return err
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
}
|
|
||||||
defer cleanupPassphrase()
|
|
||||||
|
|
||||||
// Create the default vault and derive its long-term key
|
return fmt.Errorf("failed to create default vault: %w", err)
|
||||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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()
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Prompt for passphrase for unlocker
|
||||||
|
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
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
@@ -232,7 +194,7 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
|||||||
passphraseBuffer1.Destroy()
|
passphraseBuffer1.Destroy()
|
||||||
passphraseBuffer2.Destroy()
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
return nil, errPassphraseMismatch
|
return nil, fmt.Errorf("passphrases do not match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the second buffer, we'll return the first
|
// Clean up the second buffer, we'll return the first
|
||||||
|
|||||||
+328
-655
File diff suppressed because it is too large
Load Diff
@@ -1,566 +0,0 @@
|
|||||||
//nolint:testpackage // sets the unexported fields of Instance
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// lockWait is how long a test waits for something that must happen
|
|
||||||
// once the lock is free.
|
|
||||||
lockWait = 10 * time.Second
|
|
||||||
|
|
||||||
// testPassphrase protects the passphrase unlockers the tests create.
|
|
||||||
testPassphrase = "test-passphrase"
|
|
||||||
|
|
||||||
// testInput is a file outside the state directory that commands read.
|
|
||||||
testInput = "/input"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockInBackground starts taking the state directory lock and returns a
|
|
||||||
// channel that delivers the function releasing it once it has been taken.
|
|
||||||
func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
taken := make(chan func(), 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
if assert.NoError(t, err) {
|
|
||||||
taken <- release
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return taken
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAtOnce runs one add of the secret name per value, all at once, and
|
|
||||||
// returns their errors. Each add is given mnemonic, which a forced add
|
|
||||||
// needs.
|
|
||||||
func addAtOnce(
|
|
||||||
fs afero.Fs, stateDir, name string, force bool, values []string,
|
|
||||||
mnemonic *memguard.LockedBuffer,
|
|
||||||
) []error {
|
|
||||||
errs := make(chan error, len(values))
|
|
||||||
|
|
||||||
for _, value := range values {
|
|
||||||
go func() {
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
cli.Mnemonic = mnemonic
|
|
||||||
cli.cmd = &cobra.Command{}
|
|
||||||
cli.cmd.SetIn(strings.NewReader(value))
|
|
||||||
|
|
||||||
errs <- cli.AddSecret(name, force)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]error, 0, len(values))
|
|
||||||
for range values {
|
|
||||||
results = append(results, <-errs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// numbered returns count distinct values starting with prefix.
|
|
||||||
func numbered(prefix string, count int) []string {
|
|
||||||
values := make([]string, 0, count)
|
|
||||||
for i := range count {
|
|
||||||
values = append(values, prefix+"-"+strconv.Itoa(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
|
|
||||||
// the in-memory and on the real filesystem. Without the state directory
|
|
||||||
// lock, adds of a new secret all find it absent and replace each other, and
|
|
||||||
// forced adds read the same highest version number and overwrite each
|
|
||||||
// other's version. With it they behave as if run one after another.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
const adds = 8
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
fs afero.Fs
|
|
||||||
stateDir string
|
|
||||||
}{
|
|
||||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// One add creates the secret; the others find that it exists
|
|
||||||
created := 0
|
|
||||||
|
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
|
||||||
numbered("create", adds), mnemonic) {
|
|
||||||
if err == nil {
|
|
||||||
created++
|
|
||||||
} else {
|
|
||||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, 1, created, "exactly one add creates the secret")
|
|
||||||
|
|
||||||
// Every forced add stores a version of its own
|
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
|
||||||
numbered("force", adds), mnemonic) {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(tc.fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "shared"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, adds+1, "one version per successful add")
|
|
||||||
|
|
||||||
values := make(map[string]bool, len(versions))
|
|
||||||
|
|
||||||
for _, version := range versions {
|
|
||||||
value, err := vlt.GetSecretVersion("shared", version)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
values[string(value.Bytes())] = true
|
|
||||||
value.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, values, adds+1, "every add stored its own value")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readNotifier passes reads through to Reader and closes reading at the
|
|
||||||
// first one.
|
|
||||||
type readNotifier struct {
|
|
||||||
io.Reader
|
|
||||||
|
|
||||||
reading chan struct{}
|
|
||||||
once sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *readNotifier) Read(p []byte) (int, error) {
|
|
||||||
r.once.Do(func() { close(r.reading) })
|
|
||||||
|
|
||||||
return r.Reader.Read(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in
|
|
||||||
// one process, starting encrypt once add is reading its input. Had add
|
|
||||||
// taken the state directory lock before reading, it would hold the lock
|
|
||||||
// while waiting for encrypt's output, and encrypt would wait for the lock
|
|
||||||
// to store its key: neither would finish.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
|
||||||
|
|
||||||
pipeReader, pipeWriter := io.Pipe()
|
|
||||||
// If the test gives up, this makes add's read fail, so that both
|
|
||||||
// commands return and release the lock the other tests use
|
|
||||||
t.Cleanup(func() { _ = pipeReader.Close() })
|
|
||||||
|
|
||||||
const commands = 2
|
|
||||||
|
|
||||||
input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})}
|
|
||||||
results := make(chan error, commands)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
add := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
add.cmd = &cobra.Command{}
|
|
||||||
add.cmd.SetIn(input)
|
|
||||||
|
|
||||||
results <- add.AddSecret("encrypted", false)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-input.reading
|
|
||||||
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(pipeWriter)
|
|
||||||
|
|
||||||
err := encrypt.Encrypt("key", testInput, "")
|
|
||||||
// Ends add's input, as the end of the pipe does
|
|
||||||
_ = pipeWriter.CloseWithError(err)
|
|
||||||
|
|
||||||
results <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
timeout := time.After(lockWait)
|
|
||||||
|
|
||||||
for range commands {
|
|
||||||
select {
|
|
||||||
case err := <-results:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatal("secret encrypt piped into secret add never finished")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedCommandReleasesLock checks that a command failing after it
|
|
||||||
// took the state directory lock leaves the lock free for the next command.
|
|
||||||
func TestFailedCommandReleasesLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
// Fails once it holds the lock: there is no current vault. Without
|
|
||||||
// --force it would fail before taking the lock, on the check it makes
|
|
||||||
// before asking.
|
|
||||||
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-lockInBackground(t, fs):
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the failed command left the state directory locked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateDirModTimes returns the modification time of every file and
|
|
||||||
// directory under the test state directory. Any change a command makes, even
|
|
||||||
// rewriting a file with the same content, changes it.
|
|
||||||
func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
modTimes := make(map[string]int64)
|
|
||||||
|
|
||||||
err := afero.Walk(fs, testStateDir,
|
|
||||||
func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
modTimes[path] = info.ModTime().UnixNano()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return modTimes
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupEveryCommand makes what each command in
|
|
||||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
|
||||||
// versions of "test/secret", the vault "other" without a long-term key, for
|
|
||||||
// vault import, and the file testInput. There is no vault "default", which
|
|
||||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
|
||||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
|
||||||
// ID.
|
|
||||||
func setupEveryCommand(
|
|
||||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
otherDir, err := other.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
|
||||||
addTestSecret(t, vlt, []byte("newer"), true)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 2)
|
|
||||||
|
|
||||||
unlockerID := ""
|
|
||||||
|
|
||||||
if withUnlocker {
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
unlockerID = unlocker.GetID()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
||||||
|
|
||||||
// Newest first
|
|
||||||
return versions[1], unlockerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitingForLock reports whether a goroutine is stopped in
|
|
||||||
// vault.LockStateDir, waiting for the in-memory filesystem's lock. The
|
|
||||||
// stack trace of such a goroutine starts with the reason it waits,
|
|
||||||
// "[sync.Mutex.Lock]", and names LockStateDir.
|
|
||||||
func waitingForLock() bool {
|
|
||||||
stacks := make([]byte, 1<<20)
|
|
||||||
stacks = stacks[:runtime.Stack(stacks, true)]
|
|
||||||
|
|
||||||
for goroutine := range bytes.SplitSeq(stacks, []byte("\n\n")) {
|
|
||||||
if bytes.Contains(goroutine, []byte("[sync.Mutex.Lock")) &&
|
|
||||||
bytes.Contains(goroutine, []byte("vault.LockStateDir(")) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
|
||||||
// while holding the state directory lock. The command must neither finish
|
|
||||||
// nor change anything before it waits for the lock, and must succeed once
|
|
||||||
// the lock is released.
|
|
||||||
func requireWaitsForLock(
|
|
||||||
t *testing.T,
|
|
||||||
withUnlocker bool,
|
|
||||||
run func(cli *Instance, olderVersion, unlockerID string) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
|
||||||
before := stateDirModTimes(t, fs)
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Released at most once, and also if the test fails while holding it,
|
|
||||||
// so that later tests can take it
|
|
||||||
release = sync.OnceFunc(release)
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
defer unlockPassphrase.Destroy()
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
cli.UnlockPassphrase = unlockPassphrase
|
|
||||||
cli.cmd = &cobra.Command{}
|
|
||||||
cli.cmd.SetIn(strings.NewReader("value"))
|
|
||||||
cli.cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
|
||||||
|
|
||||||
timeout := time.After(lockWait)
|
|
||||||
|
|
||||||
for !waitingForLock() {
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
t.Fatalf("finished while the lock was held, with error %v", err)
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatal("never waited for the lock")
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, before, stateDirModTimes(t, fs),
|
|
||||||
"changed the state directory before waiting for the lock")
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("did not finish once the lock was released")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChangingCommandsWaitForLock checks that each command that changes the
|
|
||||||
// state directory waits for its lock.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
|
||||||
func TestChangingCommandsWaitForLock(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
withUnlocker bool
|
|
||||||
run func(cli *Instance, olderVersion, unlockerID string) error
|
|
||||||
}{
|
|
||||||
{"add", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.AddSecret("added", false)
|
|
||||||
}},
|
|
||||||
{"import", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.ImportSecret(cli.cmd, "imported", testInput, false)
|
|
||||||
}},
|
|
||||||
{"generate secret", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.GenerateSecret(cli.cmd, "generated", 16, "base58", false)
|
|
||||||
}},
|
|
||||||
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.Encrypt("key", testInput, "")
|
|
||||||
}},
|
|
||||||
{"rm --force", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.RemoveSecret(cli.cmd, "test/secret", true)
|
|
||||||
}},
|
|
||||||
{"move", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
|
||||||
}},
|
|
||||||
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
|
||||||
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
|
||||||
}},
|
|
||||||
{"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
|
|
||||||
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
|
|
||||||
}},
|
|
||||||
{"vault create", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.CreateVault(cli.cmd, "created")
|
|
||||||
}},
|
|
||||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.SelectVault(cli.cmd, "other")
|
|
||||||
}},
|
|
||||||
{"vault import", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.VaultImport(cli.cmd, "other")
|
|
||||||
}},
|
|
||||||
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.RemoveVault(cli.cmd, "other", true)
|
|
||||||
}},
|
|
||||||
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
||||||
}},
|
|
||||||
{"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
|
|
||||||
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
|
||||||
}},
|
|
||||||
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
|
||||||
return cli.UnlockerSelect(unlockerID)
|
|
||||||
}},
|
|
||||||
{"init", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.Init(cli.cmd)
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
requireWaitsForLock(t, tc.withUnlocker, tc.run)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
|
||||||
// key that already exists, which only reads the state directory, finishes
|
|
||||||
// while another command holds the state directory lock.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
||||||
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.Mnemonic = mnemonic
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
// Stores the key
|
|
||||||
require.NoError(t, encrypt.Encrypt("key", testInput, ""))
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Also frees a waiting encrypt if the test fails, so that it releases
|
|
||||||
// the lock the other tests use
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- encrypt.Encrypt("key", testInput, "") }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("secret encrypt with an existing key waited for the lock")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptStreamsUnlocked checks that secret encrypt has released the
|
|
||||||
// state directory lock by the time it writes its output. Holding it while
|
|
||||||
// streaming would stall every other changing command for as long as the
|
|
||||||
// stream lasts, and forever when the other end of the pipe is one of them.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
|
||||||
|
|
||||||
outputReader, outputWriter := io.Pipe()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(outputWriter)
|
|
||||||
|
|
||||||
err := encrypt.Encrypt("key", testInput, "")
|
|
||||||
_ = outputWriter.CloseWithError(err)
|
|
||||||
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
// The first byte of output: encrypt is streaming now, and blocked
|
|
||||||
// writing until it is read
|
|
||||||
_, err = io.ReadFull(outputReader, make([]byte, 1))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
taken := lockInBackground(t, fs)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-taken:
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
// Let encrypt finish, so that it releases the lock, then free it
|
|
||||||
// again for the tests that follow
|
|
||||||
_, _ = io.Copy(io.Discard, outputReader)
|
|
||||||
|
|
||||||
(<-taken)()
|
|
||||||
t.Fatal("secret encrypt held the lock while streaming")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(io.Discard, outputReader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, <-done)
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
|
|
||||||
// onto itself deleted it, also when "work" was spelled two ways, and a failed
|
|
||||||
// move within "work" left "work" the current vault. "default" is the current
|
|
||||||
// vault in every case, and each case runs on its own copy of the state
|
|
||||||
// directory.
|
|
||||||
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
||||||
|
|
||||||
const (
|
|
||||||
ontoItself = "secret 'x' cannot be moved onto itself"
|
|
||||||
workX = "work:x"
|
|
||||||
)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
source, dest string
|
|
||||||
force bool
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"mv x x", "x", "x", false, ontoItself},
|
|
||||||
{"mv --force x x", "x", "x", true, ontoItself},
|
|
||||||
{"mv --force work:x work:", workX, "work:", true, ontoItself},
|
|
||||||
// An empty destination name defaults to the source name.
|
|
||||||
{`mv --force work:x ""`, workX, "", true, ontoItself},
|
|
||||||
// "work" is a vault name, so the destination is work:x.
|
|
||||||
{"mv --force work:x work", workX, "work", true, ontoItself},
|
|
||||||
{
|
|
||||||
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
|
||||||
"secret 'nosuch' not found",
|
|
||||||
},
|
|
||||||
// Only an existing vault is used.
|
|
||||||
{
|
|
||||||
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
|
||||||
"vault 'nosuch' does not exist",
|
|
||||||
},
|
|
||||||
// Each of these spells "work" a second way. The spelling is not a
|
|
||||||
// valid vault name, so the move is not taken for a move between two
|
|
||||||
// vaults, which would delete the destination, here the source.
|
|
||||||
{
|
|
||||||
"mv --force work:x work/:x", workX, "work/:x", true,
|
|
||||||
vault.ValidateVaultName("work/").Error(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work/:x work:", "work/:x", "work:", true,
|
|
||||||
vault.ValidateVaultName("work/").Error(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work:x ./work:x", workX, "./work:x", true,
|
|
||||||
vault.ValidateVaultName("./work").Error(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
||||||
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
require.EqualError(t, err, tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
|
|
||||||
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
|
|
||||||
// leaves "default" the current vault.
|
|
||||||
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
after := snapshotStateDir(t, fs)
|
|
||||||
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
|
|
||||||
|
|
||||||
require.Equal(t, "default", after[testStateDir+"/currentvault"])
|
|
||||||
require.Contains(t, after, workSecrets+"y/")
|
|
||||||
require.NotContains(t, after, workSecrets+"x/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive
|
|
||||||
// filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo`
|
|
||||||
// removed the destination, which was the source. Symbolic links on the real
|
|
||||||
// filesystem give one secret two names here: in "default", "y" is a link to
|
|
||||||
// the secret "x", and the secrets.d of "other" is a link to that of
|
|
||||||
// "default", so other:x is default:x. Each move must be rejected and leave
|
|
||||||
// the secret and the links as they were.
|
|
||||||
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const isSame = "is the same secret on this filesystem"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
source, dest string
|
|
||||||
force bool
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"mv --force y x", "y", "x", true,
|
|
||||||
"secret 'y' cannot be moved onto itself: 'x' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force x y", "x", "y", true,
|
|
||||||
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv x y", "x", "y", false,
|
|
||||||
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force default:x other:x", "default:x", "other:x", true,
|
|
||||||
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
|
||||||
isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv default:x other", "default:x", "other", false,
|
|
||||||
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
|
||||||
isSame,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
|
||||||
|
|
||||||
// "default" is created last, so it is the current vault.
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d")
|
|
||||||
otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d")
|
|
||||||
link := filepath.Join(defaultSecrets, "y")
|
|
||||||
|
|
||||||
require.NoError(t, os.Symlink("x", link))
|
|
||||||
require.NoError(t, os.Remove(otherSecrets))
|
|
||||||
require.NoError(t, os.Symlink(defaultSecrets, otherSecrets))
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, []byte("value"), value.Bytes())
|
|
||||||
|
|
||||||
target, err := os.Readlink(link)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "x", target)
|
|
||||||
|
|
||||||
target, err = os.Readlink(otherSecrets)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, defaultSecrets, target)
|
|
||||||
|
|
||||||
require.EqualError(t, moveErr, tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo"
|
|
||||||
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
|
|
||||||
// with "Foo".
|
|
||||||
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo"))
|
|
||||||
if err == nil {
|
|
||||||
t.Skip("the temporary directory is on a case-insensitive filesystem")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("foo")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, []byte("upper"), value.Bytes())
|
|
||||||
|
|
||||||
_, err = vlt.GetSecret("Foo")
|
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
|
||||||
}
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// testStateDir is the in-memory state directory of the test vaults.
|
|
||||||
testStateDir = "/test/state"
|
|
||||||
|
|
||||||
// testPassphrase protects the passphrase unlocker of each test vault.
|
|
||||||
testPassphrase = "test-passphrase"
|
|
||||||
|
|
||||||
// testVersion is a version name in the format the vault uses.
|
|
||||||
testVersion = "20260101.001"
|
|
||||||
|
|
||||||
// missingFile is an import source that does not exist, so an import
|
|
||||||
// that opened it before checking the name would fail with another error.
|
|
||||||
missingFile = "/no/such/file"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
|
|
||||||
// Creating a passphrase unlocker is slow by design, so the vaults are made
|
|
||||||
// once, by the first test that needs them.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
|
|
||||||
var (
|
|
||||||
twoVaultsOnce sync.Once
|
|
||||||
twoVaults map[string]string
|
|
||||||
)
|
|
||||||
|
|
||||||
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
|
||||||
// and "default", the current one. Each holds the secret "x" and a
|
|
||||||
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
|
||||||
// Every call returns a new copy of the same vaults.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
||||||
func newTwoVaultFs(t *testing.T) afero.Fs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
twoVaultsOnce.Do(func() {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
for _, name := range []string{"work", "default"} {
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(
|
|
||||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
twoVaults = snapshotStateDir(t, fs)
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
|
|
||||||
|
|
||||||
return newFsFromSnapshot(t, twoVaults)
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshotStateDir maps every file under the state directory to its
|
|
||||||
// contents, and every directory, written with a trailing "/", to "". Two
|
|
||||||
// snapshots are equal only if nothing in it was added, removed or changed.
|
|
||||||
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
tree := map[string]string{}
|
|
||||||
|
|
||||||
err := afero.Walk(fs, testStateDir, func(
|
|
||||||
path string, info os.FileInfo, err error,
|
|
||||||
) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
tree[path+"/"] = ""
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := afero.ReadFile(fs, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tree[path] = string(content)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return tree
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
|
|
||||||
// directories and files recorded by snapshotStateDir.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
||||||
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
// In sorted order every directory comes before its contents.
|
|
||||||
for _, path := range slices.Sorted(maps.Keys(tree)) {
|
|
||||||
dir, isDir := strings.CutSuffix(path, "/")
|
|
||||||
if isDir {
|
|
||||||
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireRejectedAndUnchanged runs a command on a copy of the state
|
|
||||||
// directory recorded in before. It requires an error with exactly the
|
|
||||||
// message of want, so that a later check rejecting the argument does not
|
|
||||||
// count, and everything under the state directory as it was: the error
|
|
||||||
// alone proves nothing, since it could come after the vault had already
|
|
||||||
// been deleted.
|
|
||||||
func requireRejectedAndUnchanged(
|
|
||||||
t *testing.T, before map[string]string, want error,
|
|
||||||
run func(c *cli.Instance) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
|
|
||||||
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
|
||||||
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
require.EqualError(t, err, want.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
|
||||||
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
|
||||||
// Removals, moves and imports use --force, so that only the name check
|
|
||||||
// stands in the way.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|
||||||
// Creating a passphrase unlocker is slow by design, so the vaults are
|
|
||||||
// created once and each case runs on its own copy of them.
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
vaultDir := testStateDir + "/vaults.d/default"
|
|
||||||
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
|
||||||
require.Contains(t, before, vaultDir+"/current-unlocker")
|
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
rejected string // the secret name the command must reject
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{"rm --force ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.RemoveSecret(cmd, "..", true)
|
|
||||||
}},
|
|
||||||
{"rm --force .", ".", func(c *cli.Instance) error {
|
|
||||||
return c.RemoveSecret(cmd, ".", true)
|
|
||||||
}},
|
|
||||||
{`rm --force ""`, "", func(c *cli.Instance) error {
|
|
||||||
return c.RemoveSecret(cmd, "", true)
|
|
||||||
}},
|
|
||||||
{"rm --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
||||||
return c.RemoveSecret(cmd, "../../etc", true)
|
|
||||||
}},
|
|
||||||
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "..", "x", true)
|
|
||||||
}},
|
|
||||||
{"mv --force x ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "x", "..", true)
|
|
||||||
}},
|
|
||||||
{`mv --force x ""`, "", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "x", "", true)
|
|
||||||
}},
|
|
||||||
// "work" is not the current vault: a move within it must not
|
|
||||||
// select it when a name is rejected.
|
|
||||||
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
|
||||||
}},
|
|
||||||
{"mv --force work:x work:..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
|
||||||
}},
|
|
||||||
{"mv --force default:.. work", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "default:..", "work", true)
|
|
||||||
}},
|
|
||||||
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
|
||||||
}},
|
|
||||||
{"mv --force default:x work:..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
|
||||||
}},
|
|
||||||
{"import --force ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.ImportSecret(cmd, "..", missingFile, true)
|
|
||||||
}},
|
|
||||||
{"import --force .", ".", func(c *cli.Instance) error {
|
|
||||||
return c.ImportSecret(cmd, ".", missingFile, true)
|
|
||||||
}},
|
|
||||||
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
||||||
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
|
||||||
}},
|
|
||||||
{"version list ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.ListVersions(cmd, "..")
|
|
||||||
}},
|
|
||||||
{"version promote ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.PromoteVersion(cmd, "..", testVersion)
|
|
||||||
}},
|
|
||||||
{"version rm --force ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.RemoveVersion(cmd, "..", testVersion, true)
|
|
||||||
}},
|
|
||||||
{"encrypt ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.Encrypt("..", "", "")
|
|
||||||
}},
|
|
||||||
{"decrypt ..", "..", func(c *cli.Instance) error {
|
|
||||||
return c.Decrypt("..", "", "")
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/67, where
|
|
||||||
// `secret version rm x ../../..` deleted the whole vault,
|
|
||||||
// `secret version rm x ..` the secret x, and `secret version rm x .` or
|
|
||||||
// `secret version rm x ""` every version of x. A version argument is
|
|
||||||
// accepted only if it is one of the versions `secret version list` lists.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
commands := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *cli.Instance, version string) error
|
|
||||||
}{
|
|
||||||
{"version rm --force x", func(c *cli.Instance, version string) error {
|
|
||||||
return c.RemoveVersion(cmd, "x", version, true)
|
|
||||||
}},
|
|
||||||
{"version promote x", func(c *cli.Instance, version string) error {
|
|
||||||
return c.PromoteVersion(cmd, "x", version)
|
|
||||||
}},
|
|
||||||
{"get x --version", func(c *cli.Instance, version string) error {
|
|
||||||
return c.GetSecretWithVersion(cmd, "x", version)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range commands {
|
|
||||||
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
|
||||||
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
|
||||||
want := fmt.Errorf("version '%s' %w '%s'",
|
|
||||||
version, vault.ErrVersionNotFound, "x")
|
|
||||||
requireRejectedAndUnchanged(t, before, want,
|
|
||||||
func(c *cli.Instance) error { return tt.run(c, version) })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
|
||||||
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
|
||||||
// state directory itself, and `secret vault select ..` made it the current
|
|
||||||
// vault. Each command that takes a vault name must reject an invalid one
|
|
||||||
// before building a path from it. The instance is given the mnemonic and
|
|
||||||
// the passphrase, and moves and removals use --force, so that only the name
|
|
||||||
// check stands in the way.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
// Each command is a format with %q where the vault name goes.
|
|
||||||
commands := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *cli.Instance, name string) error
|
|
||||||
}{
|
|
||||||
{"vault create %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.CreateVault(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault import %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.VaultImport(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault select %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.SelectVault(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault remove --force %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.RemoveVault(cmd, name, true)
|
|
||||||
}},
|
|
||||||
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
|
|
||||||
return c.MoveSecret(cmd, name+":x", "work:x", true)
|
|
||||||
}},
|
|
||||||
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
|
|
||||||
return c.MoveSecret(cmd, "default:x", name+":x", true)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range commands {
|
|
||||||
for _, name := range []string{"", ".", "..", "a/b"} {
|
|
||||||
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
|
||||||
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
c.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
return tt.run(c, name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveVersionRemovesOnlyThatVersion checks that
|
|
||||||
// `secret version rm --force` with a version that is not the current one
|
|
||||||
// removes that version and changes nothing else.
|
|
||||||
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// A second version of "x" becomes the current one.
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 2)
|
|
||||||
|
|
||||||
// ListVersions lists the newest version first.
|
|
||||||
oldDir := secretDir + "/versions/" + versions[1] + "/"
|
|
||||||
before := snapshotStateDir(t, fs)
|
|
||||||
require.Contains(t, before, oldDir)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Expected: the state as before without everything under oldDir.
|
|
||||||
want := map[string]string{}
|
|
||||||
|
|
||||||
for path, content := range before {
|
|
||||||
if !strings.HasPrefix(path, oldDir) {
|
|
||||||
want[path] = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
|
||||||
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
|
||||||
// in the current vault and changes nothing else.
|
|
||||||
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
err := c.MoveSecret(&cobra.Command{}, "x", "work", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Expected: the state as before, with everything under the current
|
|
||||||
// vault's secrets.d/x/ now under secrets.d/work/.
|
|
||||||
oldDir := testStateDir + "/vaults.d/default/secrets.d/x/"
|
|
||||||
newDir := testStateDir + "/vaults.d/default/secrets.d/work/"
|
|
||||||
want := map[string]string{}
|
|
||||||
|
|
||||||
for path, content := range before {
|
|
||||||
rest, found := strings.CutPrefix(path, oldDir)
|
|
||||||
if found {
|
|
||||||
path = newDir + rest
|
|
||||||
}
|
|
||||||
|
|
||||||
want[path] = content
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Contains(t, want, newDir)
|
|
||||||
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
||||||
}
|
|
||||||
+8
-54
@@ -4,72 +4,26 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry runs the secret CLI and returns the process exit code. It wipes
|
// Entry is the entry point for the secret CLI application
|
||||||
// every memguard buffer before it returns, so the caller must do nothing
|
func Entry() {
|
||||||
// but exit with the code.
|
cmd := newRootCmd()
|
||||||
func Entry() int {
|
if err := cmd.Execute(); err != nil {
|
||||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
os.Exit(1)
|
||||||
// and exits with status 1. The passphrase prompt turns terminal echo
|
|
||||||
// off until the read finishes, so a signal there would leave echo off.
|
|
||||||
// Only a process in the terminal's foreground process group may reset
|
|
||||||
// it: one in the background that tries is stopped instead of exiting.
|
|
||||||
terminalState, terminalErr := term.GetState(unix.Stdin)
|
|
||||||
|
|
||||||
memguard.CatchSignal(func(os.Signal) {
|
|
||||||
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
|
|
||||||
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
|
|
||||||
_ = term.Restore(unix.Stdin, terminalState)
|
|
||||||
}
|
}
|
||||||
}, os.Interrupt, unix.SIGTERM)
|
|
||||||
|
|
||||||
defer memguard.Purge()
|
|
||||||
|
|
||||||
err := newRootCmd().Execute()
|
|
||||||
if err != nil {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRootCmd() *cobra.Command {
|
func newRootCmd() *cobra.Command {
|
||||||
secret.Debug("newRootCmd starting")
|
secret.Debug("newRootCmd starting")
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "secret",
|
Use: "secret",
|
||||||
Short: "A simple secrets manager",
|
Short: "A simple secrets manager",
|
||||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
Long: `A simple secrets manager to store and retrieve sensitive information securely.`,
|
||||||
`information securely.`,
|
// Ensure usage is shown after errors
|
||||||
// Cobra prints the error a command returns; Entry does not.
|
SilenceUsage: false,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
// Usage belongs only to a command called wrongly. Cobra has
|
|
||||||
// checked its arguments and flag values before this runs, but
|
|
||||||
// checks required flags (ValidateRequiredFlags) and flag groups
|
|
||||||
// (ValidateFlagGroups) only after it, so both are checked here
|
|
||||||
// to keep usage for them. An error after that comes from running
|
|
||||||
// the command, and usage would only bury it. A subcommand that
|
|
||||||
// sets its own PersistentPreRun replaces this one.
|
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
err := cmd.ValidateRequiredFlags()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cmd.ValidateFlagGroups()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.SilenceUsage = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Adding subcommands to root command")
|
secret.Debug("Adding subcommands to root command")
|
||||||
|
|||||||
+239
-522
File diff suppressed because it is too large
Load Diff
+199
-219
@@ -1,4 +1,3 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,198 +9,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testVaultName is the vault name used by the size tests.
|
|
||||||
const testVaultName = "test-vault"
|
|
||||||
|
|
||||||
// lockedBytesPerSecretByte bounds the locked memory that storing a secret
|
|
||||||
// holds at once: the buffers it is read into reach up to 1.5 times its
|
|
||||||
// size, and they are then copied into one more buffer of its size.
|
|
||||||
const lockedBytesPerSecretByte = 3
|
|
||||||
|
|
||||||
// skipIfLockedMemoryTooLow skips the test when this process cannot lock
|
|
||||||
// the memory a secret of size bytes needs, found by locking a buffer of
|
|
||||||
// that size and releasing it. memguard panics, ending the whole test run,
|
|
||||||
// when it cannot lock a buffer, and a plain `docker build .` runs the
|
|
||||||
// tests under an 8 MiB locked-memory limit (RLIMIT_MEMLOCK). A process
|
|
||||||
// allowed to lock past that limit runs every case.
|
|
||||||
func skipIfLockedMemoryTooLow(t *testing.T, size int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
need := lockedBytesPerSecretByte * size
|
|
||||||
|
|
||||||
buf, err := unix.Mmap(-1, 0, need,
|
|
||||||
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
lockErr := unix.Mlock(buf)
|
|
||||||
|
|
||||||
// Unmapping the buffer also unlocks it.
|
|
||||||
err = unix.Munmap(buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if lockErr != nil {
|
|
||||||
var limit unix.Rlimit
|
|
||||||
|
|
||||||
err = unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Skipf("a %d-byte secret needs up to %d bytes of locked memory, "+
|
|
||||||
"which could not be locked under the locked-memory limit "+
|
|
||||||
"(RLIMIT_MEMLOCK) of %d bytes: %v",
|
|
||||||
size, need, limit.Cur, lockErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
|
|
||||||
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()
|
|
||||||
skipIfLockedMemoryTooLow(t, size)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"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()
|
|
||||||
skipIfLockedMemoryTooLow(t, size)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
//
|
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
||||||
func TestAddSecretVariousSizes(t *testing.T) {
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -254,14 +71,76 @@ func TestAddSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
//
|
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
||||||
func TestImportSecretVariousSizes(t *testing.T) {
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -314,14 +193,73 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
//
|
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
||||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Test various sizes that should trigger buffer growth
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
@@ -345,9 +283,31 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||||
skipIfLockedMemoryTooLow(t, size)
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
// 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
|
// Create test data of exactly the specified size
|
||||||
// Use a pattern that's easy to verify
|
// Use a pattern that's easy to verify
|
||||||
@@ -356,18 +316,20 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
testData[i] = byte(i % 256)
|
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 := &cobra.Command{}
|
||||||
cmd.SetIn(bytes.NewReader(testData))
|
cmd.SetIn(stdin)
|
||||||
|
|
||||||
// Create CLI instance
|
// Create CLI instance
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.fs = fs
|
cli.fs = fs
|
||||||
cli.stateDir = testStateDir
|
cli.stateDir = stateDir
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Test adding the secret
|
// Test adding the secret
|
||||||
@@ -378,41 +340,58 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original exactly")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||||
t.Parallel()
|
// Set up test environment
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
// 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
|
// Create a custom reader that simulates slow streaming input
|
||||||
// This will help verify our buffer handling works correctly with partial reads
|
// This will help verify our buffer handling works correctly with partial reads
|
||||||
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||||
streamingStdin := &slowReader{
|
slowReader := &slowReader{
|
||||||
data: testData,
|
data: testData,
|
||||||
chunkSize: 1000, // Read 1KB at a time
|
chunkSize: 1000, // Read 1KB at a time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create command with slow reader as stdin
|
// Create command with slow reader as stdin
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
cmd.SetIn(streamingStdin)
|
cmd.SetIn(slowReader)
|
||||||
|
|
||||||
// Create CLI instance
|
// Create CLI instance
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.fs = fs
|
cli.fs = fs
|
||||||
cli.stateDir = testStateDir
|
cli.stateDir = stateDir
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Test adding the secret
|
// Test adding the secret
|
||||||
@@ -422,11 +401,7 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret("streaming-test")
|
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// slowReader simulates a reader that returns data in small chunks
|
// slowReader simulates a reader that returns data in small chunks
|
||||||
@@ -436,22 +411,27 @@ type slowReader struct {
|
|||||||
chunkSize int
|
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) {
|
if r.offset >= len(r.data) {
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read at most chunkSize bytes, bounded by the remaining data and
|
// Read at most chunkSize bytes
|
||||||
// the destination buffer
|
|
||||||
remaining := len(r.data) - r.offset
|
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
|
r.offset += n
|
||||||
|
|
||||||
if r.offset >= len(r.data) {
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret value to stdout, not stderr
|
||||||
// value to stdout, not stderr
|
|
||||||
func TestGetCommandOutputsToStdout(t *testing.T) {
|
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||||
// Create a temporary directory for our vault
|
// Create a temporary directory for our vault
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Set environment variables for the test
|
// Set environment variables for the test
|
||||||
t.Setenv(secret.EnvStateDir, tempDir)
|
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
||||||
|
|
||||||
// Find the secret binary path
|
// Find the secret binary path
|
||||||
wd, err := filepath.Abs("../..")
|
wd, err := filepath.Abs("../..")
|
||||||
require.NoError(t, err, "should get working directory")
|
require.NoError(t, err, "should get working directory")
|
||||||
|
|
||||||
secretPath := filepath.Join(wd, "secret")
|
secretPath := filepath.Join(wd, "secret")
|
||||||
|
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
testPassphrase := "test-passphrase"
|
testPassphrase := "test-passphrase"
|
||||||
|
|
||||||
// Initialize vault
|
// Initialize vault
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
cmd := exec.Command(secretPath, "init")
|
||||||
cmd := exec.CommandContext(t.Context(), secretPath, "init")
|
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
secret.EnvStateDir + "=" + tempDir,
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
"SB_UNLOCK_PASSPHRASE=" + testPassphrase,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "init should succeed: %s", string(output))
|
require.NoError(t, err, "init should succeed: %s", string(output))
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
cmd = exec.Command(secretPath, "add", "test/secret")
|
||||||
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
secret.EnvStateDir + "=" + tempDir,
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
cmd.Stdin = strings.NewReader("test-secret-value")
|
cmd.Stdin = strings.NewReader("test-secret-value")
|
||||||
|
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "add should succeed: %s", string(output))
|
require.NoError(t, err, "add should succeed: %s", string(output))
|
||||||
|
|
||||||
// Test that 'secret get' outputs to stdout, not stderr
|
// Test that 'secret get' outputs to stdout, not stderr
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
cmd = exec.Command(secretPath, "get", "test/secret")
|
||||||
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
|
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
secret.EnvStateDir + "=" + tempDir,
|
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@@ -72,8 +65,7 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
|||||||
require.NoError(t, err, "get should succeed")
|
require.NoError(t, err, "get should succeed")
|
||||||
|
|
||||||
// The secret value should be in stdout
|
// The secret value should be in stdout
|
||||||
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()),
|
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()), "secret value should be in stdout")
|
||||||
"secret value should be in stdout")
|
|
||||||
|
|
||||||
// Nothing should be in stderr
|
// Nothing should be in stderr
|
||||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||||
func ExecuteCommandInProcess(
|
func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) {
|
||||||
args []string, stdin string, env map[string]string,
|
|
||||||
) (string, error) {
|
|
||||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||||
|
|
||||||
// Save current environment
|
// Save current environment
|
||||||
@@ -45,13 +43,11 @@ func ExecuteCommandInProcess(
|
|||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
secret.Debug("Command execution completed",
|
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
||||||
"error", err, "outputLength", len(output), "output", output)
|
|
||||||
|
|
||||||
// Add debug info for troubleshooting
|
// Add debug info for troubleshooting
|
||||||
if len(output) == 0 && err == nil {
|
if len(output) == 0 && err == nil {
|
||||||
secret.Debug("Warning: Command executed successfully but produced no output",
|
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
|
||||||
"args", args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore environment
|
// Restore environment
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
package cli_test
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:paralleltest // executes the CLI in-process against shared state
|
|
||||||
func TestOutputCapture(t *testing.T) {
|
func TestOutputCapture(t *testing.T) {
|
||||||
// Test vault list command which we fixed
|
// 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)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
||||||
t.Logf("vault list output: %q", output)
|
t.Logf("vault list output: %q", output)
|
||||||
|
|
||||||
// Test help command
|
// Test help command
|
||||||
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, output, "help output should not be empty")
|
assert.NotEmpty(t, output, "help output should not be empty")
|
||||||
t.Logf("help output length: %d", len(output))
|
t.Logf("help output length: %d", len(output))
|
||||||
|
|||||||
+247
-495
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
|
||||||
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
|
||||||
|
|
||||||
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
|
|
||||||
const (
|
|
||||||
addTestSecretName = "api-key"
|
|
||||||
addTestSecretValue = "value"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
|
|
||||||
// with a passphrase unlocker, getting the vault's long-term key from the
|
|
||||||
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
|
|
||||||
// then reads a secret with neither the mnemonic nor the passphrase given, so
|
|
||||||
// through the new unlocker, which the add selects.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlocker(t *testing.T) {
|
|
||||||
newTestGPGKey(t)
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
// mnemonic is the mnemonic given while the unlocker is added, or nil.
|
|
||||||
mnemonic *memguard.LockedBuffer
|
|
||||||
}{
|
|
||||||
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
|
|
||||||
{"long-term key from the current unlocker", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret(addTestSecretName,
|
|
||||||
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(
|
|
||||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
instance.Mnemonic = test.mnemonic
|
|
||||||
instance.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
||||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
|
||||||
|
|
||||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
|
||||||
|
|
||||||
current, err := reopened.GetCurrentUnlocker()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, unlockerTypePGP, current.GetType())
|
|
||||||
|
|
||||||
value, err := reopened.GetSecret(addTestSecretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, addTestSecretValue, value.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
|
||||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
|
||||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
|
||||||
// lookup moved after anything is written would also come after getting the
|
|
||||||
// vault's long-term key, which fails first here: this vault's unlockers hold
|
|
||||||
// no keys.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
|
||||||
newTestGPGKey(t)
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
instance, cmd := newTestInstance(base)
|
|
||||||
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
|
|
||||||
|
|
||||||
err := instance.addPGPUnlocker(cmd)
|
|
||||||
|
|
||||||
require.ErrorContains(t, err, "failed to resolve GPG key fingerprint")
|
|
||||||
assertDirEntries(t, base,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
|
||||||
listTestUnlockerDirOne)
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
// Corrupt Unlocker Tests
|
|
||||||
//
|
|
||||||
// `secret unlocker select` and `secret unlocker remove` find an unlocker
|
|
||||||
// by its ID. These tests give the first unlocker, which sorts before the
|
|
||||||
// one the commands act on, metadata that is not JSON, and check that the
|
|
||||||
// commands step past it, and that it can itself be removed by its
|
|
||||||
// directory name, which `secret unlocker list` names in its warning. A
|
|
||||||
// last test checks that an unlocker whose metadata file cannot be read
|
|
||||||
// counts as the last unlocker when it is removed by its directory name.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
|
||||||
// metadata of the first unlocker replaced by text that is not JSON.
|
|
||||||
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := newListTestVault(t, 2)
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirOne, listTestMetadataFileName),
|
|
||||||
[]byte("not json"), listTestFilePerm))
|
|
||||||
|
|
||||||
return fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
|
|
||||||
// can be selected, and that the corrupt one, having no type to be used as,
|
|
||||||
// cannot be selected by its directory name.
|
|
||||||
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newCorruptUnlockerVault(t)
|
|
||||||
instance, _ := newTestInstance(fs)
|
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
|
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, listTestUnlockerDirTwo, string(current))
|
|
||||||
|
|
||||||
err = instance.UnlockerSelect(listTestUnlockerDirOne)
|
|
||||||
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
|
|
||||||
// counts as the vault's last one, since the corrupt unlocker cannot unlock
|
|
||||||
// the vault, and that the corrupt one, removed by its directory name, does
|
|
||||||
// not. Either is removed once the user confirms.
|
|
||||||
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
unlockerID string
|
|
||||||
wantLast bool
|
|
||||||
wantEntries []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "the other unlocker",
|
|
||||||
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
|
||||||
wantLast: true,
|
|
||||||
wantEntries: []string{listTestUnlockerDirOne},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "the corrupt unlocker by its directory name",
|
|
||||||
unlockerID: listTestUnlockerDirOne,
|
|
||||||
wantEntries: []string{listTestUnlockerDirTwo},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newCorruptUnlockerVault(t)
|
|
||||||
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
|
|
||||||
found, err := instance.findUnlockerToRemove(tt.unlockerID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.wantLast, found.last)
|
|
||||||
|
|
||||||
instance.terminal = strings.NewReader("y\n")
|
|
||||||
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
|
|
||||||
|
|
||||||
assertDirEntries(t, fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName),
|
|
||||||
listTestUnlockersDirName),
|
|
||||||
tt.wantEntries...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
|
|
||||||
// of a vault with secrets, removed by its directory name when its metadata
|
|
||||||
// file cannot be checked for or read, counts as the vault's last unlocker,
|
|
||||||
// so the question warns that it is: listing leaves it out, but it may
|
|
||||||
// still be the vault's only working unlocker. It is then removed. The
|
|
||||||
// state directory lock refuses the failing filesystem, so the test calls
|
|
||||||
// findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
|
|
||||||
// make its checks and, once it holds the lock, to remove the unlocker.
|
|
||||||
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
||||||
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
|
|
||||||
listTestMetadataFileName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wrap func(base afero.Fs) afero.Fs
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "checking for the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reading the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(tt.wrap(base))
|
|
||||||
|
|
||||||
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, found.last)
|
|
||||||
assert.Contains(t, found.question, "the last unlocker")
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
|
|
||||||
assertDirEntries(t, base, unlockersDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
// Unlocker List Tests
|
|
||||||
//
|
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
|
||||||
// or an unlocker's metadata in it, 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.
|
|
||||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
|
||||||
// metadata does not stop the others from being listed.
|
|
||||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
|
||||||
// file cannot be checked for or read is left out, and the other is
|
|
||||||
// still listed.
|
|
||||||
//
|
|
||||||
// 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"
|
|
||||||
"os"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errMetadataUnreadable is returned by the test filesystem in place of a
|
|
||||||
// successful open of one unlocker's metadata file.
|
|
||||||
var errMetadataUnreadable = errors.New("input/output error")
|
|
||||||
|
|
||||||
// metadataReadFailFs fails every open of the file at unreadablePath. The
|
|
||||||
// file still exists, so checking for it succeeds and only reading it fails.
|
|
||||||
type metadataReadFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
unreadablePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
||||||
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
|
|
||||||
if name == f.unreadablePath {
|
|
||||||
return nil, errMetadataUnreadable
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
|
||||||
return f.Fs.Open(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errMetadataUncheckable is returned by the test filesystem in place of a
|
|
||||||
// successful check for one unlocker's metadata file.
|
|
||||||
var errMetadataUncheckable = errors.New("permission denied")
|
|
||||||
|
|
||||||
// metadataStatFailFs fails every check for whether the file at
|
|
||||||
// uncheckablePath exists, as when its unlocker directory cannot be entered.
|
|
||||||
type metadataStatFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
uncheckablePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
|
||||||
if name == f.uncheckablePath {
|
|
||||||
return nil, errMetadataUncheckable
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
|
||||||
return f.Fs.Stat(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
|
||||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
|
||||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
|
||||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
metadata string
|
|
||||||
wantIDs []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "not JSON",
|
|
||||||
metadata: "not json",
|
|
||||||
wantIDs: []string{healthyID},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GPG key ID of the wrong type",
|
|
||||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GPG key ID missing",
|
|
||||||
metadata: `{"type": "pgp"}`,
|
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newListTestVault(t, 2)
|
|
||||||
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
|
|
||||||
listTestVaultName, listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirTwo, listTestMetadataFileName)
|
|
||||||
require.NoError(t, afero.WriteFile(
|
|
||||||
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
|
|
||||||
))
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
require.Len(t, unlockers, len(tt.wantIDs))
|
|
||||||
|
|
||||||
for i, wantID := range tt.wantIDs {
|
|
||||||
assert.Equal(t, wantID, unlockers[i].ID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
|
|
||||||
// metadata file cannot be checked for or cannot be read is left out of the
|
|
||||||
// listing, and the other unlocker is still listed with its real ID. The
|
|
||||||
// failing one sorts first, so finding the other's ID has to step past it
|
|
||||||
// as well.
|
|
||||||
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
failingPath := filepath.Join(listTestStateDir, "vaults.d",
|
|
||||||
listTestVaultName, listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirOne, listTestMetadataFileName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wrap func(base afero.Fs) afero.Fs
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "checking for the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reading the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := tt.wrap(newListTestVault(t, 2))
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
|
||||||
"only the unlocker with usable metadata may be listed")
|
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
|
||||||
"the listed row must carry the real unlocker ID")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
// Unreadable Directory Tests
|
|
||||||
//
|
|
||||||
// The checks that guard adding a PGP unlocker (is this key already an
|
|
||||||
// unlocker?), removing the last unlocker and removing a vault (does the
|
|
||||||
// vault hold secrets?), removing a secret (how many versions does it
|
|
||||||
// have?), and importing a mnemonic (does the vault already have a
|
|
||||||
// long-term key?) each look at the vault on disk before acting.
|
|
||||||
// When that look fails they must refuse to act, not read the failure as
|
|
||||||
// "nothing there" and go ahead.
|
|
||||||
//
|
|
||||||
// The tests make the look fail with a wrapper around the in-memory
|
|
||||||
// filesystem, which the state directory lock refuses. So they call the
|
|
||||||
// function each command runs once it holds the lock, such as addPGPUnlocker
|
|
||||||
// for UnlockersAdd, or, for a removal, the function that makes its checks,
|
|
||||||
// such as findVaultToRemove for RemoveVault, which runs again under the
|
|
||||||
// lock before anything is removed, with --force or without.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"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 (
|
|
||||||
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
|
|
||||||
// PGP unlocker tests generate, and the --keyid they pass.
|
|
||||||
unreadableTestGPGUserID = "unlocker-test@example.com"
|
|
||||||
|
|
||||||
// unreadableTestSecretName is the secret stored in the vaults the
|
|
||||||
// removal tests remove from.
|
|
||||||
unreadableTestSecretName = "api-key"
|
|
||||||
|
|
||||||
// unreadableTestOtherVault is a second vault for the vault removal
|
|
||||||
// test, since the last vault can never be removed.
|
|
||||||
unreadableTestOtherVault = "work"
|
|
||||||
|
|
||||||
// unreadableTestSecretsDirName is the directory holding a vault's
|
|
||||||
// secrets, and unreadableTestCurrentFileName the per-secret file
|
|
||||||
// naming its current version.
|
|
||||||
unreadableTestSecretsDirName = "secrets.d"
|
|
||||||
unreadableTestCurrentFileName = "current"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errStatFailed is returned by statFailFs in place of a successful stat.
|
|
||||||
var errStatFailed = errors.New("input/output error")
|
|
||||||
|
|
||||||
// statFailFs fails every Stat of one path, as an I/O or permission error
|
|
||||||
// on that path would.
|
|
||||||
type statFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
|
|
||||||
if name == f.path {
|
|
||||||
return nil, errStatFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.Stat(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errOpenFailed is returned by openFailFs in place of a successful open.
|
|
||||||
var errOpenFailed = errors.New("permission denied")
|
|
||||||
|
|
||||||
// openFailFs fails every Open of one path, as a directory without read
|
|
||||||
// permission does: checking that it exists succeeds, listing it fails.
|
|
||||||
type openFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
||||||
func (f *openFailFs) Open(name string) (afero.File, error) {
|
|
||||||
if name == f.path {
|
|
||||||
return nil, errOpenFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.Open(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testVaultDir returns the directory of the named vault in the synthetic
|
|
||||||
// state directory built by newListTestVault.
|
|
||||||
func testVaultDir(vaultName string) string {
|
|
||||||
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestInstance returns a CLI instance on fs whose output is discarded.
|
|
||||||
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
|
|
||||||
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertDirEntries asserts that dir holds exactly the named entries.
|
|
||||||
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
names = append(names, entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, want, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
|
||||||
// without a passphrase there, with a subkey for encryption, and returns the
|
|
||||||
// key's fingerprint.
|
|
||||||
func newTestGPGKey(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
|
|
||||||
// socket, which is created inside GNUPGHOME there.
|
|
||||||
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
|
|
||||||
t.Setenv("GNUPGHOME", gnupgHome)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
// Stop the gpg-agent that key generation starts; cleanups run in
|
|
||||||
// reverse order, so this happens before its directory is removed.
|
|
||||||
// t.Context is already canceled when cleanup runs.
|
|
||||||
ctx := context.WithoutCancel(t.Context())
|
|
||||||
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
||||||
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
||||||
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
|
|
||||||
).CombinedOutput()
|
|
||||||
require.NoError(t, err, "generating the test GPG key: %s", output)
|
|
||||||
|
|
||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
|
|
||||||
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
||||||
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
||||||
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
|
|
||||||
).CombinedOutput()
|
|
||||||
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
|
|
||||||
output)
|
|
||||||
|
|
||||||
return fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
|
|
||||||
// against fs.
|
|
||||||
func addTestPGPUnlocker(fs afero.Fs) error {
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
||||||
|
|
||||||
return instance.addPGPUnlocker(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
|
|
||||||
// a key that already has one fails, and creates no unlocker directory,
|
|
||||||
// when unlockers.d or the existing unlocker's metadata file cannot be
|
|
||||||
// read; and, as the control case, that the existing unlocker is refused
|
|
||||||
// as a duplicate when everything can be read.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
|
|
||||||
fingerprint := newTestGPGKey(t)
|
|
||||||
unlockersDir := filepath.Join(
|
|
||||||
testVaultDir(listTestVaultName), listTestUnlockersDirName)
|
|
||||||
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
|
|
||||||
|
|
||||||
// newVaultWithDuplicate returns a vault holding an unlocker for the
|
|
||||||
// test key, beside the one newListTestVault writes.
|
|
||||||
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
|
|
||||||
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
|
|
||||||
fingerprint)
|
|
||||||
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
failFs func(base afero.Fs) afero.Fs
|
|
||||||
wantErr error
|
|
||||||
// wantPath is the path the error must name.
|
|
||||||
wantPath string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unlockers.d unreadable",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &unlockersDirFailFs{Fs: base}
|
|
||||||
},
|
|
||||||
wantErr: errUnlockersDirUnreadable,
|
|
||||||
wantPath: unlockersDir,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "existing unlocker's metadata unreadable",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{
|
|
||||||
Fs: base,
|
|
||||||
unreadablePath: filepath.Join(
|
|
||||||
duplicateDir, listTestMetadataFileName),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: errMetadataUnreadable,
|
|
||||||
wantPath: duplicateDir,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
base := newVaultWithDuplicate(t)
|
|
||||||
|
|
||||||
err := addTestPGPUnlocker(tt.failFs(base))
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
||||||
assert.Contains(t, err.Error(), tt.wantPath,
|
|
||||||
"the error must name what it could not read")
|
|
||||||
assertDirEntries(t, base, unlockersDir,
|
|
||||||
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("duplicate refused", func(t *testing.T) {
|
|
||||||
base := newVaultWithDuplicate(t)
|
|
||||||
|
|
||||||
err := addTestPGPUnlocker(base)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
||||||
assertDirEntries(t, base, unlockersDir,
|
|
||||||
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeTestSecret stores a secret with a current-version pointer, which is
|
|
||||||
// what makes it count as a secret, in the given vault directory.
|
|
||||||
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
secretDir := filepath.Join(
|
|
||||||
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
|
|
||||||
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(secretDir, unreadableTestCurrentFileName),
|
|
||||||
[]byte("20260809.001"), listTestFilePerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
|
|
||||||
// unlocker is kept when the secrets it protects cannot be counted.
|
|
||||||
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
||||||
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
secretsDir,
|
|
||||||
filepath.Join(secretsDir, unreadableTestSecretName,
|
|
||||||
unreadableTestCurrentFileName),
|
|
||||||
} {
|
|
||||||
t.Run(filepath.Base(path), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
|
||||||
|
|
||||||
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
|
||||||
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
|
|
||||||
// kept when whether it holds secrets cannot be determined: when checking
|
|
||||||
// that secrets.d exists fails, and when it exists but cannot be listed.
|
|
||||||
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(unreadableTestOtherVault)
|
|
||||||
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
failFs func(base afero.Fs) afero.Fs
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "check fails",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &statFailFs{Fs: base, path: secretsDir}
|
|
||||||
},
|
|
||||||
wantErr: errStatFailed,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "listing fails",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &openFailFs{Fs: base, path: secretsDir}
|
|
||||||
},
|
|
||||||
wantErr: errOpenFailed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
instance, _ := newTestInstance(tt.failFs(base))
|
|
||||||
|
|
||||||
_, err := instance.findVaultToRemove(unreadableTestOtherVault)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(base, vaultDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "the vault must not be removed")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSecretAbortsWhenVersionsUnreadable asserts that a secret is
|
|
||||||
// kept when its versions directory exists but cannot be listed, so that
|
|
||||||
// the question cannot say how many versions would be removed.
|
|
||||||
func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
secretDir := filepath.Join(testVaultDir(listTestVaultName),
|
|
||||||
unreadableTestSecretsDirName, unreadableTestSecretName)
|
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, testVaultDir(listTestVaultName))
|
|
||||||
require.NoError(t, base.MkdirAll(versionsDir, listTestDirPerm))
|
|
||||||
|
|
||||||
instance, _ := newTestInstance(&openFailFs{Fs: base, path: versionsDir})
|
|
||||||
|
|
||||||
_, err := instance.findSecretToRemove(unreadableTestSecretName)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errOpenFailed)
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(base, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "the secret must not be removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
|
|
||||||
// stops when whether the vault already has a long-term key cannot be
|
|
||||||
// determined.
|
|
||||||
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
instance, cmd := newTestInstance(&statFailFs{
|
|
||||||
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
|
||||||
})
|
|
||||||
|
|
||||||
err := instance.importMnemonic(cmd, listTestVaultName)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// usageHeading starts the usage text cobra prints after an error.
|
|
||||||
const usageHeading = "Usage:"
|
|
||||||
|
|
||||||
// A command called wrongly gets usage after its error; a command that
|
|
||||||
// fails while running gets its error alone. Either way the command fails
|
|
||||||
// and its error is shown exactly once.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
|
||||||
func TestUsageOnlyForCallErrors(t *testing.T) {
|
|
||||||
// No vault in the state directory, so `get x` fails while running.
|
|
||||||
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
call string
|
|
||||||
wantUsage bool
|
|
||||||
}{
|
|
||||||
{call: "get", wantUsage: true},
|
|
||||||
{call: "get x y", wantUsage: true},
|
|
||||||
{call: "get --no-such-flag x", wantUsage: true},
|
|
||||||
{call: "generate secret x --length abc", wantUsage: true},
|
|
||||||
{call: "import x", wantUsage: true},
|
|
||||||
{call: "get x", wantUsage: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
|
|
||||||
require.Error(t, err, "%q should fail", tt.call)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(output, err.Error()),
|
|
||||||
"%q should show its error once:\n%s", tt.call, output)
|
|
||||||
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
|
|
||||||
"usage shown for %q:\n%s", tt.call, output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+170
-340
@@ -2,11 +2,10 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,20 +18,6 @@ import (
|
|||||||
"github.com/tyler-smith/go-bip39"
|
"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")
|
|
||||||
)
|
|
||||||
|
|
||||||
func newVaultCmd() *cobra.Command {
|
func newVaultCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "vault",
|
Use: "vault",
|
||||||
@@ -51,7 +36,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
|
|
||||||
func newVaultListCmd() *cobra.Command {
|
func newVaultListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: cmdUseList,
|
Use: "list",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List available vaults",
|
Short: "List available vaults",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
@@ -82,9 +67,6 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.CreateVault(cmd, args[0])
|
return cli.CreateVault(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -121,8 +103,7 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "import <vault-name>",
|
Use: "import <vault-name>",
|
||||||
Short: "Import a mnemonic into a vault",
|
Short: "Import a mnemonic into a vault",
|
||||||
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
||||||
`(default if not specified).`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -136,9 +117,6 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.VaultImport(cmd, vaultName)
|
return cli.VaultImport(cmd, vaultName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -149,22 +127,16 @@ func newVaultRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <name>",
|
Use: "remove <name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a vault",
|
Short: "Remove a vault",
|
||||||
Long: `Remove a vault and all its secrets. Asks for ` +
|
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
||||||
`confirmation first, naming how many secrets the vault ` +
|
`switch to another vault if removing the currently selected one.`,
|
||||||
`holds; when stdin is not a terminal, fails unless --force ` +
|
|
||||||
`is given. Will automatically switch to another vault if ` +
|
|
||||||
`removing the currently selected one. The last vault ` +
|
|
||||||
`cannot be removed.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
@@ -174,8 +146,7 @@ func newVaultRemoveCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false,
|
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
|
||||||
"Remove without asking for confirmation, even a vault that contains secrets")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -190,13 +161,11 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||||
// Get current vault name for context
|
// Get current vault name for context
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
|
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err == nil {
|
|
||||||
currentVault = currentVlt.GetName()
|
currentVault = currentVlt.GetName()
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]any{
|
result := map[string]interface{}{
|
||||||
"vaults": vaults,
|
"vaults": vaults,
|
||||||
"currentVault": currentVault,
|
"currentVault": currentVault,
|
||||||
}
|
}
|
||||||
@@ -205,20 +174,16 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println(string(jsonBytes))
|
cmd.Println(string(jsonBytes))
|
||||||
} else {
|
} else {
|
||||||
// Text output
|
// Text output
|
||||||
cmd.Println("Available vaults:")
|
cmd.Println("Available vaults:")
|
||||||
|
|
||||||
if len(vaults) == 0 {
|
if len(vaults) == 0 {
|
||||||
cmd.Println(" (none)")
|
cmd.Println(" (none)")
|
||||||
} else {
|
} else {
|
||||||
// Try to get current vault for marking
|
// Try to get current vault for marking
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
|
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err == nil {
|
|
||||||
currentVault = currentVlt.GetName()
|
currentVault = currentVlt.GetName()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,73 +200,60 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePassphrase returns the unlock passphrase from the environment,
|
|
||||||
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
|
|
||||||
// returned cleanup function must be deferred by the caller.
|
|
||||||
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
|
|
||||||
if cli.UnlockPassphrase != nil {
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
|
||||||
|
|
||||||
return cli.UnlockPassphrase, func() {}, 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, nil, fmt.Errorf("failed to read passphrase: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault
|
||||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
// Get or prompt for mnemonic
|
||||||
|
var mnemonicStr string
|
||||||
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
mnemonicStr = envMnemonic
|
||||||
|
} else {
|
||||||
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
// Read mnemonic securely without echo
|
||||||
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
return err
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
}
|
}
|
||||||
defer cleanupMnemonic()
|
|
||||||
|
|
||||||
mnemonicStr := mnemonic.String()
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
return errMnemonicEmpty
|
return fmt.Errorf("mnemonic cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonicStr)
|
mnemonicWords := strings.Fields(mnemonicStr)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
return errInvalidMnemonicPhrase
|
return fmt.Errorf("invalid BIP39 mnemonic phrase")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for the unlocker passphrase before creating the vault, so that
|
// Set mnemonic in environment for CreateVault to use
|
||||||
// stopping at the prompt leaves no vault without an unlocker behind
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
if err != nil {
|
defer func() {
|
||||||
return err
|
if originalMnemonic != "" {
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
}
|
}
|
||||||
defer cleanupPassphrase()
|
}()
|
||||||
|
|
||||||
// Create the vault - it will handle key derivation internally
|
// Create the vault - it will handle key derivation internally
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
// Get the vault metadata to retrieve the derivation index
|
||||||
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
@@ -316,9 +268,23 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Get or prompt for passphrase
|
||||||
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
|
} else {
|
||||||
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
|
// Use secure passphrase input with confirmation
|
||||||
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
@@ -333,14 +299,7 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
|
|
||||||
// SelectVault selects a vault as the current one
|
// SelectVault selects a vault as the current one
|
||||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
err = vault.SelectVault(cli.fs, cli.stateDir, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,65 +308,84 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultImportPreflight verifies the vault exists without a long-term key
|
// VaultImport imports a mnemonic into a specific vault
|
||||||
// and returns the vault directory, public key path, and validated mnemonic
|
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||||
func (cli *Instance) vaultImportPreflight(
|
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
|
||||||
vlt *vault.Vault, vaultName string,
|
|
||||||
) (string, string, string, error) {
|
// Get the specific vault by name
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||||
|
|
||||||
// Check if vault exists
|
// Check if vault exists
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||||
if err != nil {
|
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 {
|
if !exists {
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
||||||
vaultName, errVaultDoesNotExist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault already has a public key
|
// Check if vault already has a public key
|
||||||
pubKeyPath := vaultDir + "/pub.age"
|
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||||
|
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
||||||
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
|
||||||
vaultName, errVaultHasLongTermKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get mnemonic from environment
|
// Get mnemonic from environment
|
||||||
if cli.Mnemonic == nil {
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
return "", "", "", errMnemonicEnvNotSet
|
if mnemonic == "" {
|
||||||
|
return fmt.Errorf("SB_SECRET_MNEMONIC environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := cli.Mnemonic.String()
|
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonic)
|
mnemonicWords := strings.Fields(mnemonic)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonic) {
|
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
|
// Load existing metadata
|
||||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If metadata doesn't exist, create new
|
// If metadata doesn't exist, create new
|
||||||
existingMetadata = &vault.Metadata{
|
existingMetadata = &vault.Metadata{
|
||||||
@@ -420,111 +398,30 @@ func updateVaultImportMetadata(
|
|||||||
existingMetadata.PublicKeyHash = publicKeyHash
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
existingMetadata.MnemonicFamilyHash = familyHash
|
existingMetadata.MnemonicFamilyHash = familyHash
|
||||||
|
|
||||||
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to save vault metadata", "error", err)
|
secret.Debug("Failed to save vault metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
|
||||||
// directory lock while importMnemonic runs
|
|
||||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|
||||||
err := vault.ValidateVaultName(vaultName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.importMnemonic(cmd, vaultName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// importMnemonic gives the vault a long-term key derived from the mnemonic
|
|
||||||
// and a passphrase unlocker
|
|
||||||
func (cli *Instance) importMnemonic(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 = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
|
|
||||||
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
|
// Get passphrase from environment variable
|
||||||
passphraseBuffer := cli.UnlockPassphrase
|
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||||
if passphraseBuffer == nil {
|
if passphraseStr == "" {
|
||||||
return errPassphraseEnvNotSet
|
return fmt.Errorf("SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
|
// Create secure buffer for passphrase
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
@@ -539,36 +436,65 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countVaultSecrets returns the number of secrets in the vault directory
|
// RemoveVault removes a vault with safety checks
|
||||||
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
|
// Get list of all vaults
|
||||||
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list vaults: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault exists
|
||||||
|
vaultExists := false
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v == name {
|
||||||
|
vaultExists = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !vaultExists {
|
||||||
|
return fmt.Errorf("vault '%s' does not exist", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the last vault
|
||||||
|
if len(vaults) == 1 {
|
||||||
|
return fmt.Errorf("cannot remove the last vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the current vault
|
||||||
|
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
isCurrentVault := currentVault.GetName() == name
|
||||||
|
|
||||||
|
// Load the vault to check for secrets
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault has secrets
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
hasSecrets := false
|
||||||
exists, err := afero.DirExists(cli.fs, secretsDir)
|
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
|
|
||||||
secretsDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
if err != nil {
|
if err == nil && len(entries) > 0 {
|
||||||
return 0, fmt.Errorf("failed to read secrets directory %s: %w",
|
hasSecrets = true
|
||||||
secretsDir, err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(entries), nil
|
// Require --force if vault has secrets
|
||||||
}
|
if hasSecrets && !force {
|
||||||
|
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
||||||
|
}
|
||||||
|
|
||||||
// switchAwayFromVault selects another vault as current before removal
|
// If removing current vault, switch to another vault first
|
||||||
func (cli *Instance) switchAwayFromVault(
|
if isCurrentVault {
|
||||||
cmd *cobra.Command, vaults []string, name string,
|
|
||||||
) error {
|
|
||||||
// Find another vault to switch to
|
// Find another vault to switch to
|
||||||
var newVault string
|
var newVault string
|
||||||
|
|
||||||
for _, v := range vaults {
|
for _, v := range vaults {
|
||||||
if v != name {
|
if v != name {
|
||||||
newVault = v
|
newVault = v
|
||||||
@@ -578,117 +504,21 @@ func (cli *Instance) switchAwayFromVault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Switch to the new vault
|
// Switch to the new vault
|
||||||
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
|
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveVault removes a vault and all its secrets, after asking the user
|
|
||||||
// to confirm unless force is set.
|
|
||||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
|
||||||
err := vault.ValidateVaultName(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var found vaultToRemove
|
|
||||||
|
|
||||||
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
found, err = cli.findVaultToRemove(name)
|
|
||||||
|
|
||||||
return found.question, err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// If removing current vault, switch to another vault first
|
|
||||||
if found.isCurrent {
|
|
||||||
err = cli.switchAwayFromVault(cmd, found.vaults, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the vault directory
|
// Remove the vault directory
|
||||||
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
if err := cli.fs.RemoveAll(vaultDir); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Removed vault '%s'\n", name)
|
cmd.Printf("Removed vault '%s'\n", name)
|
||||||
|
if hasSecrets {
|
||||||
if found.secrets > 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultToRemove is what removing a vault removes, as findVaultToRemove
|
|
||||||
// found it.
|
|
||||||
type vaultToRemove struct {
|
|
||||||
// dir is the vault's directory, which holds all its secrets.
|
|
||||||
dir string
|
|
||||||
secrets int
|
|
||||||
// vaults lists every vault, this one included, and isCurrent is set
|
|
||||||
// when this one is the current vault.
|
|
||||||
vaults []string
|
|
||||||
isCurrent bool
|
|
||||||
// question names what is removed, for the user to confirm.
|
|
||||||
question string
|
|
||||||
}
|
|
||||||
|
|
||||||
// findVaultToRemove checks that the vault exists and is not the last one,
|
|
||||||
// and counts its secrets.
|
|
||||||
func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
|
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(vaults, name) {
|
|
||||||
return vaultToRemove{},
|
|
||||||
fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(vaults) == 1 {
|
|
||||||
return vaultToRemove{}, errCannotRemoveLastVault
|
|
||||||
}
|
|
||||||
|
|
||||||
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return vaultToRemove{},
|
|
||||||
fmt.Errorf("failed to get current vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
vaultDir, err := vault.NewVault(cli.fs, cli.stateDir, name).GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return vaultToRemove{},
|
|
||||||
fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secrets, err := cli.countVaultSecrets(vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
return vaultToRemove{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return vaultToRemove{
|
|
||||||
dir: vaultDir,
|
|
||||||
secrets: secrets,
|
|
||||||
vaults: vaults,
|
|
||||||
isCurrent: currentVault.GetName() == name,
|
|
||||||
question: fmt.Sprintf(
|
|
||||||
"Permanently remove vault '%s' and its %d secret(s)?",
|
|
||||||
name, secrets),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
+59
-170
@@ -1,16 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -21,12 +17,6 @@ const (
|
|||||||
tabWriterPadding = 2
|
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
|
// newVersionCmd returns the version management command
|
||||||
func newVersionCmd() *cobra.Command {
|
func newVersionCmd() *cobra.Command {
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
@@ -42,8 +32,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
versionCmd := &cobra.Command{
|
versionCmd := &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Manage secret versions",
|
Short: "Manage secret versions",
|
||||||
Long: "Commands for managing secret versions including listing, " +
|
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
|
||||||
"promoting, and retrieving specific versions",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// List versions command
|
// List versions command
|
||||||
@@ -54,9 +43,6 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -65,17 +51,14 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
promoteCmd := &cobra.Command{
|
promoteCmd := &cobra.Command{
|
||||||
Use: "promote <secret-name> <version>",
|
Use: "promote <secret-name> <version>",
|
||||||
Short: "Promote a specific version to current",
|
Short: "Promote a specific version to current",
|
||||||
Long: "Updates the current symlink to point to the specified " +
|
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||||
"version without modifying timestamps",
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
ValidArgsFunction: func(
|
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
|
||||||
// Complete secret name for first arg
|
// Complete secret name for first arg
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
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
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -88,30 +71,21 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Use: "remove <secret-name> <version>",
|
Use: "remove <secret-name> <version>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a specific version of a secret",
|
Short: "Remove a specific version of a secret",
|
||||||
Long: "Remove a specific version of a secret. Cannot remove the " +
|
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
||||||
"current version. Asks for confirmation first; when stdin " +
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||||
"is not a terminal, fails unless --force is given.",
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
|
||||||
ValidArgsFunction: func(
|
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
|
||||||
// Complete secret name for first arg
|
// Complete secret name for first arg
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
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
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
return cli.RemoveVersion(cmd, args[0], args[1])
|
||||||
|
|
||||||
return cli.RemoveVersion(cmd, args[0], args[1], force)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCmd.Flags().BoolP("force", "f", false,
|
|
||||||
"Remove without asking for confirmation")
|
|
||||||
|
|
||||||
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
||||||
|
|
||||||
return versionCmd
|
return versionCmd
|
||||||
@@ -121,11 +95,6 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||||
secret.Debug("ListVersions called", "secret_name", secretName)
|
secret.Debug("ListVersions called", "secret_name", secretName)
|
||||||
|
|
||||||
err := vault.ValidateSecretName(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,11 +121,10 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found", "secret_name", secretName)
|
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
|
// List all versions
|
||||||
@@ -177,12 +145,9 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current version", "error", err)
|
secret.Debug("Failed to get current version", "error", err)
|
||||||
|
|
||||||
currentVersion = ""
|
currentVersion = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Get long-term key for decrypting metadata
|
// Get long-term key for decrypting metadata
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,36 +160,19 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
// Load and display each version's metadata
|
// Load and display each version's metadata
|
||||||
for _, version := range versions {
|
for _, version := range versions {
|
||||||
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = w.Flush()
|
|
||||||
|
|
||||||
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)
|
sv := secret.NewVersion(vlt, secretName, version)
|
||||||
|
|
||||||
// Load metadata
|
// Load metadata
|
||||||
err := sv.LoadMetadata(ltIdentity)
|
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||||
if err != nil {
|
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
||||||
secret.Warn("Failed to load version metadata",
|
|
||||||
"version", version, "error", err)
|
|
||||||
// Display version with error
|
// Display version with error
|
||||||
status := "error"
|
status := "error"
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
status = "current (error)"
|
status = "current (error)"
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||||
|
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
@@ -234,38 +182,31 @@ func printVersionRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Format timestamps
|
// Format timestamps
|
||||||
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
createdAt := "-"
|
||||||
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
if sv.Metadata.CreatedAt != nil {
|
||||||
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
createdAt = sv.Metadata.CreatedAt.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
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()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromoteVersion promotes a specific version to current
|
// PromoteVersion promotes a specific version to current
|
||||||
func (cli *Instance) PromoteVersion(
|
func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||||
cmd *cobra.Command, secretName string, version string,
|
|
||||||
) error {
|
|
||||||
err := vault.ValidateSecretName(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,19 +223,17 @@ func (cli *Instance) PromoteVersion(
|
|||||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
exists, err := secret.VersionExists(cli.fs, secretDir, version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
exists, err := afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' %w '%s'",
|
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||||
version, errVersionNotFound, secretName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the current symlink using the proper function
|
// Update the current symlink using the proper function
|
||||||
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update current version: %w", err)
|
return fmt.Errorf("failed to update current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,62 +242,17 @@ func (cli *Instance) PromoteVersion(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveVersion removes a specific version of a secret, after asking the
|
// RemoveVersion removes a specific version of a secret
|
||||||
// user to confirm unless force is set.
|
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||||
func (cli *Instance) RemoveVersion(
|
// Get current vault
|
||||||
cmd *cobra.Command, secretName string, version string, force bool,
|
|
||||||
) error {
|
|
||||||
err := vault.ValidateSecretName(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var found versionToRemove
|
|
||||||
|
|
||||||
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
found, err = cli.findVersionToRemove(secretName, version)
|
|
||||||
|
|
||||||
return found.question, err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// versionToRemove is what removing a version removes, as
|
|
||||||
// findVersionToRemove found it.
|
|
||||||
type versionToRemove struct {
|
|
||||||
// dir is the version's directory.
|
|
||||||
dir string
|
|
||||||
// question names what is removed, for the user to confirm.
|
|
||||||
question string
|
|
||||||
}
|
|
||||||
|
|
||||||
// findVersionToRemove checks that the version exists in the secret in the
|
|
||||||
// current vault and is not its current version.
|
|
||||||
func (cli *Instance) findVersionToRemove(
|
|
||||||
secretName, version string,
|
|
||||||
) (versionToRemove, error) {
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionToRemove{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionToRemove{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the encoded secret name
|
// Get the encoded secret name
|
||||||
@@ -368,44 +262,39 @@ func (cli *Instance) findVersionToRemove(
|
|||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionToRemove{},
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
fmt.Errorf("failed to check if secret exists: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return versionToRemove{},
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
exists, err = secret.VersionExists(cli.fs, secretDir, version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionToRemove{},
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
fmt.Errorf("failed to check if version exists: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
|
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||||
version, errVersionNotFound, secretName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current version
|
// Get current version
|
||||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionToRemove{},
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
fmt.Errorf("failed to get current version: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow removing the current version
|
// Don't allow removing the current version
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
return versionToRemove{}, fmt.Errorf(
|
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
||||||
"cannot remove the current version '%s'; %w",
|
|
||||||
version, errCannotRemoveCurrentVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return versionToRemove{
|
// Remove the version directory
|
||||||
dir: filepath.Join(secretDir, "versions", version),
|
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
||||||
question: fmt.Sprintf("Permanently remove version %s of secret "+
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
"'%s' from vault '%s'?", version, secretName, vlt.GetName()),
|
}
|
||||||
}, nil
|
|
||||||
|
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-139
@@ -7,7 +7,6 @@
|
|||||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||||
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
|
|
||||||
// - TestVersionCommandStructure: Tests command structure and help text
|
// - TestVersionCommandStructure: Tests command structure and help text
|
||||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||||
//
|
//
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
// - setupTestVault(): CLI test helper for vault initialization
|
// - setupTestVault(): CLI test helper for vault initialization
|
||||||
// - Uses consistent test mnemonic for reproducible testing
|
// - Uses consistent test mnemonic for reproducible testing
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,7 +22,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
@@ -35,49 +32,29 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
|
func addTestSecret(t *testing.T, vlt *vault.Vault, name string, value []byte, force bool) {
|
||||||
//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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes(value)
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
defer buffer.Destroy()
|
defer buffer.Destroy()
|
||||||
|
err := vlt.AddSecret(name, buffer, force)
|
||||||
err := vlt.AddSecret("test/secret", buffer, force)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to set up a vault with long-term key in testStateDir
|
// Helper function to set up a vault with long-term key
|
||||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||||
t.Helper()
|
// Set mnemonic for testing
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// 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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
@@ -87,36 +64,30 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Select vault
|
// Select vault
|
||||||
err = vault.SelectVault(fs, testStateDir, "default")
|
err = vault.SelectVault(fs, stateDir, "default")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersionsCommand(t *testing.T) {
|
func TestListVersionsCommand(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = cli.Mnemonic
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
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
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -141,29 +112,24 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
// Should have two version entries
|
// Should have two version entries
|
||||||
lines := strings.Split(outputStr, "\n")
|
lines := strings.Split(outputStr, "\n")
|
||||||
versionLines := 0
|
versionLines := 0
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||||
versionLines++
|
versionLines++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 2, versionLines)
|
assert.Equal(t, 2, versionLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -175,26 +141,22 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPromoteVersionCommand(t *testing.T) {
|
func TestPromoteVersionCommand(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -206,17 +168,13 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
// Current should be version-2
|
// Current should be version-2
|
||||||
value, err := vlt.GetSecret("test/secret")
|
value, err := vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
||||||
|
|
||||||
// Promote first version
|
// Promote first version
|
||||||
firstVersion := versions[1] // Older version
|
firstVersion := versions[1] // Older version
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -232,33 +190,27 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
assert.Contains(t, outputStr, firstVersion)
|
assert.Contains(t, outputStr, firstVersion)
|
||||||
|
|
||||||
// Verify current is now version-1
|
// Verify current is now version-1
|
||||||
promoted, err := vlt.GetSecret("test/secret")
|
value, err = vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
defer promoted.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("value"), false)
|
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -270,27 +222,22 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSecretWithVersion(t *testing.T) {
|
func TestGetSecretWithVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = cli.Mnemonic
|
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -301,74 +248,28 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
|
|
||||||
// Test getting the current version
|
// Test getting current version (empty version string)
|
||||||
err = cli.GetSecret(cmd, "test/secret")
|
err = cli.GetSecretWithVersion(cmd, "test/secret", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "version-2", buf.String())
|
assert.Equal(t, "version-2", buf.String())
|
||||||
|
|
||||||
// Test getting specific version
|
// Test getting specific version
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|
||||||
firstVersion := versions[1] // Older version
|
firstVersion := versions[1] // Older version
|
||||||
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "version-1", buf.String())
|
assert.Equal(t, "version-1", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSecretWritesBinaryValue(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
setupTestVault(t, fs)
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
|
|
||||||
require.False(t, utf8.Valid(value))
|
|
||||||
// A copy, since storing a value wipes the slice it came from
|
|
||||||
addTestSecret(t, vlt, bytes.Clone(value), false)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 1)
|
|
||||||
|
|
||||||
cmd := newRootCmd()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
cmd.SetOut(&buf)
|
|
||||||
|
|
||||||
// Each writes exactly the stored bytes, with no trailing newline
|
|
||||||
err = cli.GetSecret(cmd, "test/secret")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, value, buf.Bytes())
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
|
|
||||||
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, value, buf.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // reads process environment to determine the state dir
|
|
||||||
func TestVersionCommandStructure(t *testing.T) {
|
func TestVersionCommandStructure(t *testing.T) {
|
||||||
// Test that version commands are properly structured
|
// Test that version commands are properly structured
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := VersionCommands(cli)
|
cmd := VersionCommands(cli)
|
||||||
|
|
||||||
assert.Equal(t, "version", cmd.Use)
|
assert.Equal(t, "version", cmd.Use)
|
||||||
@@ -385,14 +286,12 @@ func TestVersionCommandStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := "/test/state"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs, stateDir)
|
||||||
|
|
||||||
// Create a secret directory without versions (edge case)
|
// Create a secret directory without versions (edge case)
|
||||||
vaultDir := stateDir + "/vaults.d/default"
|
vaultDir := stateDir + "/vaults.d/default"
|
||||||
@@ -402,7 +301,6 @@ func TestListVersionsEmptyOutput(t *testing.T) {
|
|||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
||||||
// Returns the uncompressed public key bytes (65 bytes) and the identity hash
|
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
|
||||||
// (for deletion).
|
|
||||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||||
@@ -58,8 +57,7 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
|
||||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
|
||||||
h := C.GoString(&hashBuf[0])
|
h := C.GoString(&hashBuf[0])
|
||||||
|
|
||||||
return pk, h, nil
|
return pk, h, nil
|
||||||
@@ -85,8 +83,7 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
|
||||||
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
|
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -110,8 +107,7 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
|
||||||
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
|
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
//go:build !darwin || !cgo
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
import "errors"
|
import "fmt"
|
||||||
|
|
||||||
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
|
var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals
|
||||||
|
|
||||||
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
|
// CreateKey is not supported on non-darwin platforms.
|
||||||
func CreateKey(_ string) ([]byte, string, error) {
|
func CreateKey(_ string) ([]byte, string, error) {
|
||||||
return nil, "", errNotSupported
|
return nil, "", errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
|
// Encrypt is not supported on non-darwin platforms.
|
||||||
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
|
// Decrypt is not supported on non-darwin platforms.
|
||||||
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
|
// DeleteKey is not supported on non-darwin platforms.
|
||||||
func DeleteKey(_ string) error {
|
func DeleteKey(_ string) error {
|
||||||
return errNotSupported
|
return errNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//go:build darwin && cgo
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
@@ -44,8 +45,7 @@ func TestCreateAndDeleteKey(t *testing.T) {
|
|||||||
|
|
||||||
// Verify valid uncompressed P-256 public key
|
// Verify valid uncompressed P-256 public key
|
||||||
if len(pubKey) != p256UncompressedKeySize {
|
if len(pubKey) != p256UncompressedKeySize {
|
||||||
t.Fatalf("expected public key length %d, got %d",
|
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
|
||||||
p256UncompressedKeySize, len(pubKey))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if pubKey[0] != 0x04 {
|
if pubKey[0] != 0x04 {
|
||||||
@@ -83,8 +83,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Test data simulating an age private key
|
// Test data simulating an age private key
|
||||||
plaintext := []byte("AGE-SECRET-KEY-1" +
|
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||||
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
|
||||||
|
|
||||||
// Encrypt
|
// Encrypt
|
||||||
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
|
||||||
// a crash at any moment, finds either the old content or the new, never a
|
|
||||||
// partial file. The data goes into a temporary file that afero.TempFile
|
|
||||||
// creates with mode 0600 in the same directory (a rename is only atomic
|
|
||||||
// within one filesystem), is synced to disk, and is renamed over path. The
|
|
||||||
// temporary file is removed if any step fails.
|
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
|
||||||
"."+filepath.Base(path)+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tmp.Write(data)
|
|
||||||
if err == nil {
|
|
||||||
err = tmp.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
closeErr := tmp.Close()
|
|
||||||
if err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
err = fs.Rename(tmp.Name(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
_ = fs.Remove(tmp.Name())
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TempDirFor creates an empty temporary directory in which to build the
|
|
||||||
// directory target before renaming it into place, or into which to move
|
|
||||||
// target before deleting it. It is made in target's grandparent: on the
|
|
||||||
// same filesystem, so the rename is atomic, and outside target's parent,
|
|
||||||
// the directory that is listed to find vaults, secrets, versions and
|
|
||||||
// unlockers, so one left behind by a crash is never taken for one of them.
|
|
||||||
// Its name leaves out target's, which may already be as long as a file name
|
|
||||||
// can be.
|
|
||||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
|
||||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to create temporary directory for %s: %w", target, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteDir calls write to write the files of the new directory dir into a
|
|
||||||
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
|
||||||
// neither a failure nor a crash leaves dir half-written; on a failure the
|
|
||||||
// temporary directory is removed, and a failure to remove it is returned
|
|
||||||
// along with the first. A directory cannot be replaced in one rename, so if
|
|
||||||
// dir already exists, WriteDir fails without calling write.
|
|
||||||
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
|
||||||
exists, err := afero.Exists(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the directory the finished one is renamed into
|
|
||||||
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := TempDirFor(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = write(tmp)
|
|
||||||
if err == nil {
|
|
||||||
err = fs.Rename(tmp, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
removeErr := fs.RemoveAll(tmp)
|
|
||||||
if removeErr != nil {
|
|
||||||
err = errors.Join(err,
|
|
||||||
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
|
||||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
|
||||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
|
||||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
|
||||||
tmp, err := TempDirFor(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
|
|
||||||
if err != nil {
|
|
||||||
_ = fs.Remove(tmp)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fs.RemoveAll(tmp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,941 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/macse"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errInjected = errors.New("injected failure")
|
|
||||||
|
|
||||||
// The kinds of change hookFs passes to before.
|
|
||||||
const (
|
|
||||||
opCreate = "create"
|
|
||||||
opOpen = "open"
|
|
||||||
opSync = "sync"
|
|
||||||
opMkdir = "mkdir"
|
|
||||||
opRemove = "remove"
|
|
||||||
opRename = "rename"
|
|
||||||
)
|
|
||||||
|
|
||||||
// currentFile is the file in a secret's directory that names its current
|
|
||||||
// version.
|
|
||||||
const currentFile = "current"
|
|
||||||
|
|
||||||
// unlockerMetadataFile is the file a new unlocker writes last.
|
|
||||||
const unlockerMetadataFile = "unlocker-metadata.json"
|
|
||||||
|
|
||||||
// privKeyFile is the file that holds the encrypted private key of a version
|
|
||||||
// or of a passphrase unlocker.
|
|
||||||
const privKeyFile = "priv.age"
|
|
||||||
|
|
||||||
// unlockerPassphrase protects the passphrase unlockers the tests create.
|
|
||||||
//
|
|
||||||
//nolint:gosec // G101: test data, not a real credential
|
|
||||||
const unlockerPassphrase = "unlocker passphrase"
|
|
||||||
|
|
||||||
// hookFs passes every call through to Fs, but first calls before for each
|
|
||||||
// call that changes the filesystem, and for each Sync of a file opened
|
|
||||||
// through it, with the path it changes (the new path, for Rename). A test
|
|
||||||
// uses before to inspect the tree at every point where a crash could stop
|
|
||||||
// the code under test, or returns an error from it to make that call fail.
|
|
||||||
// If opened is set, OpenFile also tells it the mode it opens each file with.
|
|
||||||
type hookFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
before func(op, path string) error
|
|
||||||
opened func(path string, perm os.FileMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hookFile is a file opened through hookFs.
|
|
||||||
type hookFile struct {
|
|
||||||
afero.File
|
|
||||||
|
|
||||||
before func(op, path string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f hookFile) Sync() error {
|
|
||||||
err := f.before(opSync, f.Name())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.File.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) Create(name string) (afero.File, error) {
|
|
||||||
err := h.before(opCreate, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := h.Fs.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hookFile{File: file, before: h.before}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) OpenFile(
|
|
||||||
name string, flag int, perm os.FileMode,
|
|
||||||
) (afero.File, error) {
|
|
||||||
err := h.before(opOpen, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.opened != nil {
|
|
||||||
h.opened(name, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := h.Fs.OpenFile(name, flag, perm)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hookFile{File: file, before: h.before}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
|
||||||
err := h.before(opMkdir, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Mkdir(name, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
|
||||||
err := h.before(opMkdir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.MkdirAll(path, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Remove(name string) error {
|
|
||||||
err := h.before(opRemove, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Remove(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) RemoveAll(path string) error {
|
|
||||||
err := h.before(opRemove, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.RemoveAll(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Rename(oldname, newname string) error {
|
|
||||||
err := h.before(opRename, newname)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Rename(oldname, newname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testFilesystem is a filesystem to run a test on, with a directory in it
|
|
||||||
// to work in.
|
|
||||||
type testFilesystem struct {
|
|
||||||
name string
|
|
||||||
open func(t *testing.T) (afero.Fs, string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testFilesystems are the in-memory filesystem that most tests use and the
|
|
||||||
// real one: every rename-based guarantee is checked on both.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // read-only table shared by the tests below
|
|
||||||
var testFilesystems = []testFilesystem{
|
|
||||||
{"memory", func(*testing.T) (afero.Fs, string) {
|
|
||||||
return afero.NewMemMapFs(), "/test"
|
|
||||||
}},
|
|
||||||
{"real", func(t *testing.T) (afero.Fs, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return afero.NewOsFs(), t.TempDir()
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirNames lists the names in dir.
|
|
||||||
func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
names = append(names, entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirFiles returns the contents of the files in dir, by name.
|
|
||||||
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
files := map[string]string{}
|
|
||||||
|
|
||||||
for _, name := range dirNames(t, fs, dir) {
|
|
||||||
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files[name] = string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
|
||||||
// and returns it.
|
|
||||||
func writeLongTermKey(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string,
|
|
||||||
) *age.X25519Identity {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
|
|
||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
|
||||||
[]byte(ltIdentity.Recipient().String()), 0o600))
|
|
||||||
|
|
||||||
return ltIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// newVaultWithSecret creates the vault name under stateDir from the test
|
|
||||||
// mnemonic, with a secret "shared" in it that holds value.
|
|
||||||
func newVaultWithSecret(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir, name, value string,
|
|
||||||
) *vault.Vault {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
|
||||||
defer buffer.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("shared", buffer, false))
|
|
||||||
|
|
||||||
return vlt
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteFileAtomicReplacesFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs, dir := tfs.open(t)
|
|
||||||
path := filepath.Join(dir, currentFile)
|
|
||||||
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
|
|
||||||
|
|
||||||
data, err := afero.ReadFile(fs, path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "new", string(data))
|
|
||||||
|
|
||||||
info, err := fs.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
|
|
||||||
|
|
||||||
// No temporary file is left next to it
|
|
||||||
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
path := filepath.Join(dir, currentFile)
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
|
||||||
if op == opRename {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(fs, path, []byte("new"))
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
data, err := afero.ReadFile(base, path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "old", string(data))
|
|
||||||
|
|
||||||
// The temporary file is removed again
|
|
||||||
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
|
|
||||||
// directory stands, which a crash could stop half-way, and that it leaves
|
|
||||||
// nothing behind.
|
|
||||||
func TestRemoveDirAtomic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
listed := filepath.Join(dir, "secrets.d")
|
|
||||||
target := filepath.Join(listed, "doomed")
|
|
||||||
|
|
||||||
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base,
|
|
||||||
filepath.Join(target, currentFile), []byte("20231216.001")))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRemove && strings.HasPrefix(path, target) {
|
|
||||||
t.Errorf("deleted %s where it stands", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs, target))
|
|
||||||
|
|
||||||
// Gone, and no temporary directory is left in the directory
|
|
||||||
// that is listed or in the one above it
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLongestNames adds a secret to a vault and removes the vault, both
|
|
||||||
// named with 255 bytes, the most a file name may have, on the real
|
|
||||||
// filesystem: the temporary directories they use must fit that limit too.
|
|
||||||
func TestLongestNames(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const longestName = 255
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
name := strings.Repeat("a", longestName)
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret(name, value, false))
|
|
||||||
|
|
||||||
got, err := vlt.GetSecret(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer got.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("long"), got.Bytes())
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
|
|
||||||
assert.NoDirExists(t, vaultDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
|
|
||||||
// another vault, as a forced move between vaults does, and makes the last
|
|
||||||
// step that completes the copy fail. The secret it was to replace must
|
|
||||||
// still be there unchanged: it may go only once its replacement is whole.
|
|
||||||
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
|
||||||
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
|
||||||
|
|
||||||
// The copy is complete once its current file is written
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(path) == currentFile {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err := vault.NewVault(fs, stateDir, "dest").
|
|
||||||
CopySecretAllVersions(src, "shared", "shared", true)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
value, err := dest.GetSecret("shared")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("old"), value.Bytes())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
|
|
||||||
// secret over another and removes one, and checks that none of them makes a
|
|
||||||
// directory directly in secrets.d or in a versions directory. Those are
|
|
||||||
// listed to find secrets and versions, so a temporary directory made there
|
|
||||||
// would be listed while half-built, and one left by a crash would stay.
|
|
||||||
func TestTempDirsStayOutOfListings(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
newVaultWithSecret(t, base, stateDir, "default", "first")
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
parent := filepath.Base(filepath.Dir(path))
|
|
||||||
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
|
|
||||||
t.Errorf("made %s where it is listed", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
vlt := vault.NewVault(fs, stateDir, "default")
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("second"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("shared", value, true))
|
|
||||||
require.NoError(t, vlt.AddSecret("other", value, false))
|
|
||||||
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "shared")))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
|
|
||||||
// once after it returns, that the version directory either does not exist
|
|
||||||
// or holds all of its files: a crash at any point leaves no version that
|
|
||||||
// cannot be decrypted.
|
|
||||||
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
ltIdentity := writeLongTermKey(t, base, stateDir)
|
|
||||||
|
|
||||||
var versionDir string
|
|
||||||
|
|
||||||
checkVersionDir := func(string, string) error {
|
|
||||||
exists, err := afero.DirExists(base, versionDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
assert.ElementsMatch(t,
|
|
||||||
[]string{"pub.age", "value.age", privKeyFile, "metadata.age"},
|
|
||||||
dirNames(t, base, versionDir),
|
|
||||||
"version directory visible before it was complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: checkVersionDir}
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
|
||||||
versionDir = sv.Directory
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, sv.Save(value))
|
|
||||||
require.NoError(t, checkVersionDir("", ""))
|
|
||||||
|
|
||||||
got, err := sv.GetValue(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer got.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, "whole or nothing", got.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
|
|
||||||
// private key fail, after the value has been written, and checks that
|
|
||||||
// neither the version nor its temporary directory is left behind.
|
|
||||||
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
writeLongTermKey(t, base, stateDir)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(path) == privKeyFile {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("never stored"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.ErrorIs(t, sv.Save(value), errInjected)
|
|
||||||
|
|
||||||
// The secret directory holds only the empty versions directory
|
|
||||||
versionsDir := filepath.Dir(sv.Directory)
|
|
||||||
assert.Equal(t, []string{"versions"},
|
|
||||||
dirNames(t, base, filepath.Dir(versionsDir)))
|
|
||||||
assert.Empty(t, dirNames(t, base, versionsDir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCurrentFilesNeverMissing selects the current version, vault and
|
|
||||||
// unlocker again and checks, before each change this makes, that the file
|
|
||||||
// naming the current one exists: a reader or a crash never finds it
|
|
||||||
// missing.
|
|
||||||
func TestCurrentFilesNeverMissing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
// Created as the current unlocker
|
|
||||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
|
|
||||||
version, err := secret.GetCurrentVersion(base, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
path string
|
|
||||||
reselect func(fs afero.Fs) error
|
|
||||||
}{
|
|
||||||
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
|
|
||||||
return secret.SetCurrentVersion(fs, secretDir, version)
|
|
||||||
}},
|
|
||||||
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
|
|
||||||
return vault.SelectVault(fs, stateDir, testVaultName)
|
|
||||||
}},
|
|
||||||
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
|
|
||||||
return vault.NewVault(fs, stateDir, testVaultName).
|
|
||||||
SelectUnlocker(unlocker.GetID())
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
exists, err := afero.Exists(base, tc.path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
require.NoError(t, tc.reselect(fs))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
|
|
||||||
// temporary file with mode 0600, rather than wider and narrowed later, so
|
|
||||||
// that no other user can ever read it, and syncs it before renaming it into
|
|
||||||
// place, so that a crash cannot leave the file named but its data lost.
|
|
||||||
func TestWriteFileAtomicTempFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
|
|
||||||
var modes []os.FileMode
|
|
||||||
|
|
||||||
synced := false
|
|
||||||
fs := hookFs{
|
|
||||||
Fs: base,
|
|
||||||
before: func(op, _ string) error {
|
|
||||||
switch op {
|
|
||||||
case opSync:
|
|
||||||
synced = true
|
|
||||||
case opRename:
|
|
||||||
assert.True(t, synced, "renamed before syncing")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
opened: func(_ string, perm os.FileMode) {
|
|
||||||
modes = append(modes, perm)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
|
||||||
filepath.Join(dir, currentFile), []byte("new")))
|
|
||||||
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
|
|
||||||
// vault whose long-term key cannot be had: it must fail without writing
|
|
||||||
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
|
||||||
// it would replace.
|
|
||||||
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// No mnemonic, and no current unlocker to get the key from
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
|
||||||
t.Errorf("changed %s before getting the long-term key", path)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
|
||||||
CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
|
|
||||||
// creating a passphrase unlocker makes, that the unlocker's directory either
|
|
||||||
// does not exist or holds all of its files: a crash or a failure at any point
|
|
||||||
// leaves no partial unlocker.
|
|
||||||
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// The vault has no unlocker yet, so any directory in here is
|
|
||||||
// the new one
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
for _, name := range dirNames(t, base, unlockersDir) {
|
|
||||||
assert.ElementsMatch(t, files,
|
|
||||||
dirNames(t, base, filepath.Join(unlockersDir, name)),
|
|
||||||
"unlocker directory visible before it was complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
hooked := vault.NewVault(fs, stateDir, testVaultName)
|
|
||||||
hooked.Mnemonic = vlt.Mnemonic
|
|
||||||
|
|
||||||
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
|
|
||||||
// passphrase unlocker twice, each time with only the current unlocker to open
|
|
||||||
// the vault. The first replacement fails right after making the new unlocker
|
|
||||||
// current, so the old one is not removed. The second checks, before every
|
|
||||||
// change it makes, that the vault opens with the passphrase through its
|
|
||||||
// current unlocker, which is what a crash at that change would leave; once it
|
|
||||||
// returns, the vault must have one passphrase unlocker left.
|
|
||||||
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
||||||
|
|
||||||
// Every change after the switch to the new unlocker fails
|
|
||||||
switched := false
|
|
||||||
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if switched {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
switched = op == opRename && path == currentUnlockerPath
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
|
|
||||||
replacing.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
unlockers, err := vlt.ListUnlockers()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
|
|
||||||
|
|
||||||
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
|
|
||||||
checked := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
assertOpens()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
replacing = vault.NewVault(checked, stateDir, testVaultName)
|
|
||||||
replacing.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assertOpens()
|
|
||||||
|
|
||||||
unlockers, err = vlt.ListUnlockers()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, unlockers, 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultOpensCheck returns a function that checks that the test vault under
|
|
||||||
// stateDir opens through its current unlocker, with passphrase, to the
|
|
||||||
// long-term key ltIdentity. Opening it takes a second, so an unlocker
|
|
||||||
// directory it has opened through before is not opened again: it must hold
|
|
||||||
// the same files as then.
|
|
||||||
func vaultOpensCheck(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
|
|
||||||
passphrase *memguard.LockedBuffer,
|
|
||||||
) func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
|
|
||||||
|
|
||||||
// The files of each unlocker directory the vault has opened through
|
|
||||||
opened := map[string]map[string]string{}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
|
|
||||||
|
|
||||||
if before, ok := opened[string(current)]; ok {
|
|
||||||
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opener := vault.NewVault(fs, stateDir, testVaultName)
|
|
||||||
opener.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
key, err := opener.UnlockVault()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
|
|
||||||
|
|
||||||
opened[string(current)] = files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
|
|
||||||
// a file has been written in it, and checks that neither the directory nor
|
|
||||||
// its temporary directory is left behind; and, when the temporary directory
|
|
||||||
// cannot be removed either, that both failures are reported.
|
|
||||||
func TestWriteDirFailureLeavesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
listed := filepath.Join(dir, "unlockers.d")
|
|
||||||
target := filepath.Join(listed, "new")
|
|
||||||
|
|
||||||
writeThenFail := func(tmp string) error {
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base,
|
|
||||||
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
|
|
||||||
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
err := secret.WriteDir(base, target, writeThenFail)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
// Nothing in the directory that is listed, nor beside it
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
|
||||||
if op == opRemove {
|
|
||||||
return os.ErrPermission
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err = secret.WriteDir(fs, target, writeThenFail)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
require.ErrorIs(t, err, os.ErrPermission)
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
|
|
||||||
// write, when the directory already exists, and leaves the directory as it
|
|
||||||
// was: it never writes into a directory in place.
|
|
||||||
func TestWriteDirRefusesExistingDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs, dir := tfs.open(t)
|
|
||||||
target := filepath.Join(dir, "unlockers.d", "existing")
|
|
||||||
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
|
||||||
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
|
|
||||||
|
|
||||||
err := secret.WriteDir(fs, target, func(string) error {
|
|
||||||
t.Error("write called for a directory that exists")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, os.ErrExist)
|
|
||||||
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
|
||||||
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
|
||||||
// the key must be deleted again. Skipped when the add fails before that, as
|
|
||||||
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
|
||||||
// Enclave.
|
|
||||||
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
|
||||||
var seKeyLabel string
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
|
||||||
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
|
||||||
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
|
||||||
nil)
|
|
||||||
|
|
||||||
if seKeyLabel == "" {
|
|
||||||
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
|
||||||
assert.Error(t, err, "Secure Enclave key left behind")
|
|
||||||
}
|
|
||||||
@@ -12,16 +12,9 @@ const (
|
|||||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||||
//nolint:gosec // G101: env var name, not a credential
|
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" //nolint:gosec // G101: This is an env var name, not a credential
|
||||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
|
||||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
|
|
||||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
|
||||||
// new unlocker's directory, keychain item and Secure Enclave key. It runs
|
|
||||||
// to the nanosecond, so that every new unlocker, even one added right
|
|
||||||
// after another, gets a directory of its own.
|
|
||||||
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// File system permission constants
|
// File system permission constants
|
||||||
|
|||||||
+29
-83
@@ -2,73 +2,49 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"golang.org/x/term"
|
"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
|
// EncryptToRecipient encrypts data to a recipient using age
|
||||||
// The data parameter should be a LockedBuffer for secure memory handling
|
// The data parameter should be a LockedBuffer for secure memory handling
|
||||||
func EncryptToRecipient(
|
func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([]byte, error) {
|
||||||
data *memguard.LockedBuffer, recipient age.Recipient,
|
|
||||||
) ([]byte, error) {
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, errNilDataBuffer
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
Debug("Creating age encryptor")
|
Debug("Creating age encryptor")
|
||||||
|
|
||||||
w, err := age.Encrypt(&buf, recipient)
|
w, err := age.Encrypt(&buf, recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to create encryptor", "error", err)
|
Debug("Failed to create encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Created age encryptor successfully")
|
Debug("Created age encryptor successfully")
|
||||||
Debug("Writing data to encryptor")
|
|
||||||
|
|
||||||
_, err = w.Write(data.Bytes())
|
Debug("Writing data to encryptor")
|
||||||
if err != nil {
|
if _, err := w.Write(data.Bytes()); err != nil {
|
||||||
Debug("Failed to write data to encryptor", "error", err)
|
Debug("Failed to write data to encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Wrote data to encryptor successfully")
|
Debug("Wrote data to encryptor successfully")
|
||||||
Debug("Closing encryptor")
|
|
||||||
|
|
||||||
err = w.Close()
|
Debug("Closing encryptor")
|
||||||
if err != nil {
|
if err := w.Close(); err != nil {
|
||||||
Debug("Failed to close encryptor", "error", err)
|
Debug("Failed to close encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Closed encryptor successfully")
|
Debug("Closed encryptor successfully")
|
||||||
|
|
||||||
result := buf.Bytes()
|
result := buf.Bytes()
|
||||||
@@ -78,9 +54,7 @@ func EncryptToRecipient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithIdentity decrypts data with an identity using age
|
// DecryptWithIdentity decrypts data with an identity using age
|
||||||
func DecryptWithIdentity(
|
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
|
||||||
data []byte, identity age.Identity,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||||
@@ -94,8 +68,7 @@ func DecryptWithIdentity(
|
|||||||
// Create a secure buffer for the decrypted data
|
// Create a secure buffer for the decrypted data
|
||||||
resultBuffer := memguard.NewBufferFromBytes(result)
|
resultBuffer := memguard.NewBufferFromBytes(result)
|
||||||
|
|
||||||
// Zero out the original slice to prevent plaintext from lingering
|
// Zero out the original slice to prevent plaintext from lingering in unprotected memory
|
||||||
// in unprotected memory
|
|
||||||
for i := range result {
|
for i := range result {
|
||||||
result[i] = 0
|
result[i] = 0
|
||||||
}
|
}
|
||||||
@@ -103,39 +76,17 @@ func DecryptWithIdentity(
|
|||||||
return resultBuffer, nil
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
|
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||||
// a new locked buffer. The caller must destroy it.
|
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||||
//
|
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||||
// This is best effort. age gives the key only as a string in ordinary memory.
|
|
||||||
// The bytes of that string are moved into the buffer, which overwrites them,
|
|
||||||
// although Go otherwise never changes a string; nothing else holds this one.
|
|
||||||
// The copies age makes while building the string are left in ordinary memory.
|
|
||||||
// Avoiding those would mean encoding the key here, straight into the buffer.
|
|
||||||
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
|
|
||||||
key := id.String()
|
|
||||||
|
|
||||||
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
|
|
||||||
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
|
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes(keyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
if data == nil {
|
||||||
return nil, errNilDataBuffer
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, errNilPassphraseBuffer
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create recipient directly from passphrase - unavoidable string
|
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||||
// conversion due to age API
|
|
||||||
recipient, err := age.NewScryptRecipient(passphrase.String())
|
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||||
@@ -144,18 +95,14 @@ func EncryptWithPassphrase(
|
|||||||
return EncryptToRecipient(data, recipient)
|
return EncryptToRecipient(data, recipient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithPassphrase decrypts data using a passphrase with age's
|
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||||
// scrypt-based decryption. The passphrase parameter should be a
|
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||||
// LockedBuffer for secure memory handling
|
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||||
func DecryptWithPassphrase(
|
|
||||||
encryptedData []byte, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, errNilPassphraseBuffer
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create identity directly from passphrase - unavoidable string
|
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||||
// conversion due to age API
|
|
||||||
identity, err := age.NewScryptIdentity(passphrase.String())
|
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||||
@@ -170,30 +117,29 @@ func DecryptWithPassphrase(
|
|||||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||||
// Check if stdin is a terminal
|
// Check if stdin is a terminal
|
||||||
if !term.IsTerminal(syscall.Stdin) {
|
if !term.IsTerminal(syscall.Stdin) {
|
||||||
// Not a terminal - never read passphrases from piped input
|
// Not a terminal - never read passphrases from piped input for security reasons
|
||||||
// for security reasons
|
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
|
||||||
return nil, errStdinNotTerminal
|
"(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
|
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
||||||
// interactive prompting
|
|
||||||
if !term.IsTerminal(syscall.Stderr) {
|
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
|
// Both stdin and stderr are terminals - use secure password reading
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||||
|
|
||||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||||
// Print newline to stderr since ReadPassword doesn't echo
|
|
||||||
fmt.Fprintln(os.Stderr)
|
|
||||||
|
|
||||||
if len(passphrase) == 0 {
|
if len(passphrase) == 0 {
|
||||||
return nil, errEmptyPassphrase
|
return nil, fmt.Errorf("passphrase cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer and copy the passphrase
|
// Create a secure buffer and copy the passphrase
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
|
|
||||||
// private key, and that the identity still gives that key afterwards: the
|
|
||||||
// helper overwrites the string age returned, so age must not keep it.
|
|
||||||
func TestIdentityToLockedBuffer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
buffer := secret.IdentityToLockedBuffer(identity)
|
|
||||||
defer buffer.Destroy()
|
|
||||||
|
|
||||||
parsed, err := age.ParseX25519Identity(buffer.String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
|
|
||||||
|
|
||||||
assert.Equal(t, identity.String(), buffer.String())
|
|
||||||
}
|
|
||||||
+13
-29
@@ -13,33 +13,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
debugEnabled bool //nolint:gochecknoglobals // package debug state
|
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
|
||||||
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
|
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gochecknoinits // debug logging must be ready before any package use
|
|
||||||
func init() {
|
func init() {
|
||||||
InitDebugLogging()
|
InitDebugLogging()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDebugLogging initializes the debug logging system based on the
|
// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
|
||||||
// current GODEBUG environment variable
|
|
||||||
func InitDebugLogging() {
|
func InitDebugLogging() {
|
||||||
godebug := os.Getenv("GODEBUG")
|
godebug := os.Getenv("GODEBUG")
|
||||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||||
|
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
// Create a no-op logger that discards all output
|
// Create a no-op logger that discards all output
|
||||||
debugLogger = slog.New(slog.DiscardHandler)
|
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable stderr buffering for immediate debug output when
|
// Disable stderr buffering for immediate debug output when debugging is enabled
|
||||||
// debugging is enabled
|
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||||
//nolint:dogsled // syscall.Syscall returns three values, none needed
|
|
||||||
_, _, _ = syscall.Syscall(
|
|
||||||
syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
|
||||||
|
|
||||||
// Check if STDERR is a TTY
|
// Check if STDERR is a TTY
|
||||||
isTTY := term.IsTerminal(syscall.Stderr)
|
isTTY := term.IsTerminal(syscall.Stderr)
|
||||||
@@ -63,19 +58,14 @@ func IsDebugEnabled() bool {
|
|||||||
return debugEnabled
|
return debugEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs a warning message to stderr unconditionally (visible
|
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
|
||||||
// without --verbose or debug flags)
|
|
||||||
func Warn(msg string, args ...any) {
|
func Warn(msg string, args ...any) {
|
||||||
var output strings.Builder
|
output := fmt.Sprintf("WARNING: %s", msg)
|
||||||
|
|
||||||
output.WriteString("WARNING: " + msg)
|
|
||||||
|
|
||||||
for i := 0; i+1 < len(args); i += 2 {
|
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 += "\n"
|
||||||
output.WriteString("\n")
|
fmt.Fprint(os.Stderr, output)
|
||||||
fmt.Fprint(os.Stderr, output.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a debug message with optional attributes
|
// Debug logs a debug message with optional attributes
|
||||||
@@ -83,16 +73,14 @@ func Debug(msg string, args ...any) {
|
|||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.Debug(msg, args...)
|
debugLogger.Debug(msg, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a formatted debug message with optional attributes
|
// DebugF logs a formatted debug message with optional attributes
|
||||||
func Debugf(format string, args ...any) {
|
func DebugF(format string, args ...any) {
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +89,6 @@ func DebugWith(msg string, attrs ...slog.Attr) {
|
|||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
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 {
|
if record.NumAttrs() > 0 {
|
||||||
output += " \033[33m{"
|
output += " \033[33m{"
|
||||||
first := true
|
first := true
|
||||||
|
|
||||||
record.Attrs(func(attr slog.Attr) bool {
|
record.Attrs(func(attr slog.Attr) bool {
|
||||||
if !first {
|
if !first {
|
||||||
output += ", "
|
output += ", "
|
||||||
}
|
}
|
||||||
|
|
||||||
first = false
|
first = false
|
||||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
output += "}\033[0m"
|
output += "}\033[0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//nolint:testpackage // white-box test of unexported debug internals
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -91,11 +90,9 @@ func TestDebugLogging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // exercises process-global debug logger state
|
|
||||||
func TestDebugFunctions(t *testing.T) {
|
func TestDebugFunctions(t *testing.T) {
|
||||||
// Enable debug for testing
|
// Enable debug for testing
|
||||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||||
|
|
||||||
defer InitDebugLogging() // Re-initialize after test
|
defer InitDebugLogging() // Re-initialize after test
|
||||||
|
|
||||||
InitDebugLogging()
|
InitDebugLogging()
|
||||||
@@ -110,8 +107,8 @@ func TestDebugFunctions(t *testing.T) {
|
|||||||
Debug("test with args", "key", "value", "number", 42)
|
Debug("test with args", "key", "value", "number", 42)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Debugf", func(_ *testing.T) {
|
t.Run("DebugF", func(_ *testing.T) {
|
||||||
Debugf("formatted message: %s %d", "test", 123)
|
DebugF("formatted message: %s %d", "test", 123)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DebugWith", func(_ *testing.T) {
|
t.Run("DebugWith", func(_ *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -29,43 +27,18 @@ func (v *realVault) GetDirectory() (string, error) {
|
|||||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||||
}
|
}
|
||||||
func (v *realVault) GetName() string { return v.name }
|
func (v *realVault) GetName() string { return v.name }
|
||||||
|
|
||||||
//nolint:ireturn // implements VaultInterface
|
|
||||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||||
|
|
||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
||||||
panic("not used")
|
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
||||||
}
|
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
|
|
||||||
//nolint:ireturn // implements VaultInterface
|
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) CreatePassphraseUnlocker(
|
|
||||||
*memguard.LockedBuffer,
|
|
||||||
) (*PassphraseUnlocker, error) {
|
|
||||||
panic("not used")
|
panic("not used")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||||
// filesystem, identical to what vault.CreateVault produces.
|
// filesystem, identical to what vault.CreateVault produces.
|
||||||
func createRealVault(
|
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
|
||||||
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
|
|
||||||
) *realVault {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
@@ -78,18 +51,13 @@ func createRealVault(
|
|||||||
}
|
}
|
||||||
metaBytes, err := json.Marshal(metadata)
|
metaBytes, err := json.Marshal(metadata)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||||
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
|
||||||
|
|
||||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||||
t.Parallel()
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
||||||
"abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Derive expected keys at two different indices to prove they differ.
|
// Derive expected keys at two different indices to prove they differ.
|
||||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
@@ -103,12 +71,10 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
t.Setenv(EnvMnemonic, testMnemonic)
|
||||||
defer mnemonic.Destroy()
|
|
||||||
|
|
||||||
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
result, err := getLongTermPrivateKey(fs, vault)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer result.Destroy()
|
defer result.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetermineStateDir determines the state directory based on environment
|
// DetermineStateDir determines the state directory based on environment variables and OS.
|
||||||
// variables and OS.
|
|
||||||
// It returns an error if no usable directory can be determined.
|
// It returns an error if no usable directory can be determined.
|
||||||
func DetermineStateDir(customConfigDir string) (string, error) {
|
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||||
// Check for environment variable first
|
// Check for environment variable first
|
||||||
@@ -29,14 +28,11 @@ func DetermineStateDir(customConfigDir string) (string, error) {
|
|||||||
// Fallback to a reasonable default if we can't determine user config dir
|
// Fallback to a reasonable default if we can't determine user config dir
|
||||||
homeDir, homeErr := os.UserHomeDir()
|
homeDir, homeErr := os.UserHomeDir()
|
||||||
if homeErr != nil {
|
if homeErr != nil {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
||||||
"unable to determine state directory: config dir: %w, home dir: %w",
|
|
||||||
err, homeErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
||||||
Warn("Could not determine user config directory, falling back to default",
|
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
|
||||||
"fallback", fallbackDir, "error", err)
|
|
||||||
|
|
||||||
return fallbackDir, nil
|
return fallbackDir, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
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
|
// On Darwin, os.UserHomeDir may still succeed via the password
|
||||||
// database, so we also test via an explicit empty-customConfigDir
|
// database, so we also test via an explicit empty-customConfigDir
|
||||||
// path to exercise the fallback branch.
|
// path to exercise the fallback branch.
|
||||||
t.Setenv(secret.EnvStateDir, "")
|
t.Setenv(EnvStateDir, "")
|
||||||
t.Setenv("HOME", "")
|
t.Setenv("HOME", "")
|
||||||
t.Setenv("XDG_CONFIG_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 both lookups fail, we must get an error.
|
||||||
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
||||||
// result should still be valid (non-empty, not root-relative).
|
// 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.
|
// Good — the error case is handled.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if result == "/.config/"+AppID || result == "" {
|
||||||
if result == "/.config/"+secret.AppID || result == "" {
|
t.Errorf("DetermineStateDir returned dangerous/empty path %q without error", result)
|
||||||
t.Errorf(
|
|
||||||
"DetermineStateDir returned dangerous/empty path %q without error",
|
|
||||||
result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||||
t.Setenv(secret.EnvStateDir, "/custom/state")
|
t.Setenv(EnvStateDir, "/custom/state")
|
||||||
|
result, err := DetermineStateDir("")
|
||||||
result, err := secret.DetermineStateDir("")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != "/custom/state" {
|
if result != "/custom/state" {
|
||||||
t.Errorf("expected /custom/state, got %q", result)
|
t.Errorf("expected /custom/state, got %q", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
||||||
t.Setenv(secret.EnvStateDir, "")
|
t.Setenv(EnvStateDir, "")
|
||||||
|
result, err := DetermineStateDir("/my/config")
|
||||||
result, err := secret.DetermineStateDir("/my/config")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
expected := "/my/config/" + AppID
|
||||||
expected := "/my/config/" + secret.AppID
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("expected %q, got %q", expected, result)
|
t.Errorf("expected %q, got %q", expected, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errPassphraseLength = errors.New(
|
|
||||||
"passphrase length must be a positive even number")
|
|
||||||
errPassphraseNotHex = errors.New(
|
|
||||||
"keychain passphrase must be lowercase hex")
|
|
||||||
errNoKeychainPassphrase = errors.New(
|
|
||||||
"keychain data has no agePrivKeyPassphrase string")
|
|
||||||
)
|
|
||||||
|
|
||||||
// KeychainData is what a keychain unlocker stores in the macOS keychain.
|
|
||||||
// It is stored as JSON, but encode and decodeKeychainData keep the
|
|
||||||
// passphrase out of encoding/json, which would leave copies of it in
|
|
||||||
// ordinary memory.
|
|
||||||
type KeychainData struct {
|
|
||||||
AgePublicKey string
|
|
||||||
AgePrivKeyPassphrase *memguard.LockedBuffer
|
|
||||||
EncryptedLongtermKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomPassphrase returns length random lowercase hex characters
|
|
||||||
// in a locked buffer. The caller must destroy it.
|
|
||||||
func generateRandomPassphrase(length int) (*memguard.LockedBuffer, error) {
|
|
||||||
// Each random byte becomes two hex characters.
|
|
||||||
randomBytes := hex.DecodedLen(length)
|
|
||||||
if length <= 0 || hex.EncodedLen(randomBytes) != length {
|
|
||||||
return nil, errPassphraseLength
|
|
||||||
}
|
|
||||||
|
|
||||||
random := memguard.NewBufferRandom(randomBytes)
|
|
||||||
defer random.Destroy()
|
|
||||||
|
|
||||||
passphrase := memguard.NewBuffer(length)
|
|
||||||
hex.Encode(passphrase.Bytes(), random.Bytes())
|
|
||||||
passphrase.Freeze()
|
|
||||||
|
|
||||||
return passphrase, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// encode returns d as JSON in a locked buffer:
|
|
||||||
// {"agePublicKey":"...","agePrivKeyPassphrase":"...","encryptedLongtermKey":"..."}.
|
|
||||||
// The passphrase is copied straight into the buffer, so it must be hex,
|
|
||||||
// which JSON does not escape. The caller must destroy the returned buffer.
|
|
||||||
func (d *KeychainData) encode() (*memguard.LockedBuffer, error) {
|
|
||||||
if d.AgePrivKeyPassphrase == nil {
|
|
||||||
return nil, errNilPassphraseBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.AgePrivKeyPassphrase.Size() == 0 {
|
|
||||||
return nil, errEmptyPassphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range d.AgePrivKeyPassphrase.Bytes() {
|
|
||||||
if strings.IndexByte("0123456789abcdef", c) < 0 {
|
|
||||||
return nil, errPassphraseNotHex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey, err := json.Marshal(d.AgePublicKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encode age public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
longtermKey, err := json.Marshal(d.EncryptedLongtermKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encode long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := [][]byte{
|
|
||||||
[]byte(`{"agePublicKey":`), publicKey,
|
|
||||||
[]byte(`,"agePrivKeyPassphrase":"`), d.AgePrivKeyPassphrase.Bytes(),
|
|
||||||
[]byte(`","encryptedLongtermKey":`), longtermKey,
|
|
||||||
[]byte(`}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
size := 0
|
|
||||||
for _, part := range parts {
|
|
||||||
size += len(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded := memguard.NewBuffer(size)
|
|
||||||
|
|
||||||
written := 0
|
|
||||||
for _, part := range parts {
|
|
||||||
written += copy(encoded.Bytes()[written:], part)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded.Freeze()
|
|
||||||
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeKeychainData parses keychain data written by encode. The caller
|
|
||||||
// must destroy the returned AgePrivKeyPassphrase.
|
|
||||||
func decodeKeychainData(data *memguard.LockedBuffer) (*KeychainData, error) {
|
|
||||||
if data == nil {
|
|
||||||
return nil, errNilDataBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// json.Unmarshal gives a json.RawMessage field the field's JSON text
|
|
||||||
// unchanged, in the one copy RawMessage makes; it is wiped on return.
|
|
||||||
var fields struct {
|
|
||||||
AgePublicKey string `json:"agePublicKey"`
|
|
||||||
AgePrivKeyPassphrase json.RawMessage `json:"agePrivKeyPassphrase"`
|
|
||||||
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { memguard.WipeBytes(fields.AgePrivKeyPassphrase) }()
|
|
||||||
|
|
||||||
err := json.Unmarshal(data.Bytes(), &fields)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// json.Unmarshal accepted the JSON, so text that starts with a quote is
|
|
||||||
// a whole string. The passphrase is hex, so it is the text between the
|
|
||||||
// quotes.
|
|
||||||
quoted := fields.AgePrivKeyPassphrase
|
|
||||||
if !bytes.HasPrefix(quoted, []byte(`"`)) {
|
|
||||||
return nil, errNoKeychainPassphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
return &KeychainData{
|
|
||||||
AgePublicKey: fields.AgePublicKey,
|
|
||||||
// NewBufferFromBytes wipes the bytes it copies.
|
|
||||||
AgePrivKeyPassphrase: memguard.NewBufferFromBytes(
|
|
||||||
quoted[1 : len(quoted)-1]),
|
|
||||||
EncryptedLongtermKey: fields.EncryptedLongtermKey,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateRandomPassphrase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
first, err := generateRandomPassphrase(64)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer first.Destroy()
|
|
||||||
|
|
||||||
second, err := generateRandomPassphrase(64)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer second.Destroy()
|
|
||||||
|
|
||||||
assert.Regexp(t, `^[0-9a-f]{64}$`, first.String())
|
|
||||||
assert.NotEqual(t, first.String(), second.String())
|
|
||||||
assert.False(t, first.IsMutable())
|
|
||||||
|
|
||||||
for _, length := range []int{0, -2, 63} {
|
|
||||||
_, err := generateRandomPassphrase(length)
|
|
||||||
require.ErrorIs(t, err, errPassphraseLength, "length %d", length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeychainDataEncodeDecode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte("0a1b2c3d"))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
data := KeychainData{
|
|
||||||
AgePublicKey: "age1example",
|
|
||||||
AgePrivKeyPassphrase: passphrase,
|
|
||||||
EncryptedLongtermKey: "beef",
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := data.encode()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer encoded.Destroy()
|
|
||||||
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"agePublicKey":"age1example",`+
|
|
||||||
`"agePrivKeyPassphrase":"0a1b2c3d",`+
|
|
||||||
`"encryptedLongtermKey":"beef"}`,
|
|
||||||
encoded.String())
|
|
||||||
assert.False(t, encoded.IsMutable())
|
|
||||||
|
|
||||||
decoded, err := decodeKeychainData(encoded)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer decoded.AgePrivKeyPassphrase.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, "age1example", decoded.AgePublicKey)
|
|
||||||
assert.Equal(t, "0a1b2c3d", decoded.AgePrivKeyPassphrase.String())
|
|
||||||
assert.Equal(t, "beef", decoded.EncryptedLongtermKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeychainDataEncodeRejectsBadPassphrase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
passphrase *memguard.LockedBuffer
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{"nil", nil, errNilPassphraseBuffer},
|
|
||||||
{"empty", memguard.NewBuffer(0), errEmptyPassphrase},
|
|
||||||
{
|
|
||||||
"not hex",
|
|
||||||
memguard.NewBufferFromBytes([]byte(`abc"def`)),
|
|
||||||
errPassphraseNotHex,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
data := KeychainData{AgePrivKeyPassphrase: tt.passphrase}
|
|
||||||
_, err := data.encode()
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeKeychainDataRejectsBadData(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, text := range []string{
|
|
||||||
`{"agePublicKey":"age1example"}`,
|
|
||||||
`{"agePrivKeyPassphrase":42}`,
|
|
||||||
} {
|
|
||||||
data := memguard.NewBufferFromBytes([]byte(text))
|
|
||||||
_, err := decodeKeychainData(data)
|
|
||||||
data.Destroy()
|
|
||||||
require.ErrorIs(t, err, errNoKeychainPassphrase, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
notJSON := memguard.NewBufferFromBytes([]byte(`{"agePrivKeyPassphrase":`))
|
|
||||||
defer notJSON.Destroy()
|
|
||||||
|
|
||||||
_, err := decodeKeychainData(notJSON)
|
|
||||||
|
|
||||||
var syntaxError *json.SyntaxError
|
|
||||||
require.ErrorAs(t, err, &syntaxError)
|
|
||||||
}
|
|
||||||
+259
-266
@@ -1,11 +1,11 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,40 +17,23 @@ import (
|
|||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
//
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
//nolint:revive // ALL_CAPS is intentional for this constant
|
|
||||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
|
||||||
|
|
||||||
// keychainUnlockerType is the metadata type string for keychain unlockers.
|
|
||||||
keychainUnlockerType = "keychain"
|
|
||||||
|
|
||||||
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
|
|
||||||
macOSFlag = "macos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
var (
|
|
||||||
errNotMacOS = errors.New(
|
|
||||||
"keychain unlockers are only supported on macOS")
|
|
||||||
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
|
|
||||||
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
|
|
||||||
errUnsupportedCurrentUnlocker = errors.New(
|
|
||||||
"unsupported current unlocker type for keychain unlocker creation")
|
|
||||||
)
|
|
||||||
|
|
||||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
// Keychain item name
|
// Keychain item name
|
||||||
KeychainItemName string `json:"keychainItemName"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
@@ -62,15 +45,11 @@ type KeychainUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
// KeychainData represents the data stored in the macOS keychain
|
||||||
func NewKeychainUnlocker(
|
type KeychainData struct {
|
||||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
AgePublicKey string `json:"agePublicKey"`
|
||||||
) *KeychainUnlocker {
|
AgePrivKeyPassphrase string `json:"agePrivKeyPassphrase"`
|
||||||
return &KeychainUnlocker{
|
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||||
@@ -80,11 +59,37 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
slog.String("unlocker_type", k.GetType()),
|
slog.String("unlocker_type", k.GetType()),
|
||||||
)
|
)
|
||||||
|
|
||||||
keychainData, err := k.readKeychainData()
|
// Step 1: Get keychain item name
|
||||||
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
|
||||||
|
// Step 2: Retrieve data from keychain
|
||||||
|
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
||||||
|
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Retrieved data from keychain",
|
||||||
|
slog.String("unlocker_id", k.GetID()),
|
||||||
|
slog.Int("data_length", len(keychainDataBytes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 3: Parse keychain data
|
||||||
|
var keychainData KeychainData
|
||||||
|
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
||||||
|
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
||||||
|
|
||||||
// Step 4: Read the encrypted age private key from filesystem
|
// Step 4: Read the encrypted age private key from filesystem
|
||||||
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
||||||
@@ -92,8 +97,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted age private key",
|
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||||
"error", err, "path", agePrivKeyPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -104,17 +108,16 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||||
Debug("Decrypting age private key with keychain passphrase",
|
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
||||||
"unlocker_id", k.GetID())
|
// Create secure buffer for the keychain passphrase
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
agePrivKeyBuffer, err := DecryptWithPassphrase(
|
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
||||||
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with keychain passphrase",
|
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
||||||
"error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
||||||
"failed to decrypt age private key with keychain passphrase: %w", err)
|
|
||||||
}
|
}
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
@@ -143,7 +146,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetType implements Unlocker interface
|
// GetType implements Unlocker interface
|
||||||
func (k *KeychainUnlocker) GetType() string {
|
func (k *KeychainUnlocker) GetType() string {
|
||||||
return keychainUnlockerType
|
return "keychain"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata implements Unlocker interface
|
// GetMetadata implements Unlocker interface
|
||||||
@@ -177,105 +180,58 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
// Step 1: Get keychain item name
|
// Step 1: Get keychain item name
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name during removal",
|
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
||||||
"error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Remove from keychain
|
// Step 2: Remove from keychain
|
||||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||||
|
if err := deleteFromKeychain(keychainItemName); err != nil {
|
||||||
err = deleteFromKeychain(keychainItemName)
|
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to remove keychain item",
|
|
||||||
"error", err, "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Remove directory
|
// Step 3: Remove directory
|
||||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||||
|
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||||
err = RemoveDirAtomic(k.fs, k.Directory)
|
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to remove keychain unlocker directory",
|
|
||||||
"error", err, "directory", k.Directory)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Successfully removed keychain unlocker",
|
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||||
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
||||||
|
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||||
|
return &KeychainUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetKeychainItemName returns the keychain item name from metadata
|
// GetKeychainItemName returns the keychain item name from metadata
|
||||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||||
// Load the metadata
|
// Load the metadata
|
||||||
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var keychainMetadata KeychainUnlockerMetadata
|
var keychainMetadata KeychainUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
|
||||||
err = json.Unmarshal(metadataData, &keychainMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return keychainMetadata.KeychainItemName, nil
|
return keychainMetadata.KeychainItemName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readKeychainData reads and parses the data this unlocker keeps in the
|
|
||||||
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
|
|
||||||
// returned AgePrivKeyPassphrase.
|
|
||||||
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
|
|
||||||
// Step 1: Get keychain item name
|
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Retrieve data from keychain
|
|
||||||
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to retrieve data from keychain",
|
|
||||||
"error", err, "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugWith("Retrieved data from keychain",
|
|
||||||
slog.String("unlocker_id", k.GetID()),
|
|
||||||
slog.Int("data_length", len(keychainDataBytes)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Move the keychain data into locked memory; this wipes keychainDataBytes
|
|
||||||
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
|
||||||
defer keychainDataBuffer.Destroy()
|
|
||||||
|
|
||||||
// Step 3: Parse keychain data
|
|
||||||
keychainData, err := decodeKeychainData(keychainDataBuffer)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return keychainData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
||||||
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -283,21 +239,43 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: secret-<vault>-<hostname>-<time>
|
// Format: secret-<vault>-<hostname>-<date>
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
|
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey derives the long-term private key from mnemonic when
|
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
||||||
// it is not nil, else gets it through the current unlocker, which is given
|
|
||||||
// passphrase when it is a passphrase unlocker.
|
|
||||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||||
func getLongTermPrivateKey(
|
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
// Check if mnemonic is available in environment variable
|
||||||
) (*memguard.LockedBuffer, error) {
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
if mnemonic != nil {
|
if envMnemonic != "" {
|
||||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the private key in a secure buffer
|
||||||
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault to access current unlocker
|
// Get the vault to access current unlocker
|
||||||
@@ -306,10 +284,6 @@ func getLongTermPrivateKey(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
|
||||||
passphraseUnlocker.Passphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current unlocker identity
|
// Get the current unlocker identity
|
||||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -318,43 +292,34 @@ func getLongTermPrivateKey(
|
|||||||
|
|
||||||
// Get encrypted long-term key from current unlocker, handling different types
|
// Get encrypted long-term key from current unlocker, handling different types
|
||||||
var encryptedLtPrivKey []byte
|
var encryptedLtPrivKey []byte
|
||||||
|
|
||||||
switch currentUnlocker := currentUnlocker.(type) {
|
switch currentUnlocker := currentUnlocker.(type) {
|
||||||
case *PassphraseUnlocker:
|
case *PassphraseUnlocker:
|
||||||
// Read the encrypted long-term private key from passphrase unlocker
|
// Read the encrypted long-term private key from passphrase unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||||
"from current passphrase unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case *PGPUnlocker:
|
case *PGPUnlocker:
|
||||||
// Read the encrypted long-term private key from PGP unlocker
|
// Read the encrypted long-term private key from PGP unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||||
"from current PGP unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case *KeychainUnlocker:
|
case *KeychainUnlocker:
|
||||||
// Read the encrypted long-term private key from another keychain
|
// Read the encrypted long-term private key from another keychain unlocker
|
||||||
// unlocker
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
||||||
"from current keychain unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, errUnsupportedCurrentUnlocker
|
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt long-term private key using current unlocker
|
// Decrypt long-term private key using current unlocker
|
||||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||||
encryptedLtPrivKey, currentUnlockerIdentity)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -363,51 +328,10 @@ func getLongTermPrivateKey(
|
|||||||
return ltPrivKeyBuffer, nil
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||||
// the vault's derivation index, for getLongTermPrivateKey and
|
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||||
// getLongTermKeyForSE.
|
|
||||||
func deriveLongTermPrivateKey(
|
|
||||||
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
// Read vault metadata to get the correct derivation index
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata VaultMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return IdentityToLockedBuffer(ltIdentity), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
|
|
||||||
// vault. The long-term key comes from mnemonic when it is not nil, else from
|
|
||||||
// the current unlocker, as getLongTermPrivateKey describes.
|
|
||||||
func CreateKeychainUnlocker(
|
|
||||||
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*KeychainUnlocker, error) {
|
|
||||||
// Check if we're on macOS
|
// Check if we're on macOS
|
||||||
err := checkMacOSAvailable()
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,13 +347,16 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The unlocker directory is named after the keychain item
|
// Create unlocker directory using the keychain item name as the directory name
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
@@ -437,72 +364,84 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
|
||||||
|
|
||||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
||||||
}
|
}
|
||||||
defer agePrivKeyPassphrase.Destroy()
|
|
||||||
|
|
||||||
// Step 3: Encrypt age private key with the generated passphrase
|
// Step 3: Store age recipient as plaintext
|
||||||
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
||||||
|
// Create secure buffers for both the private key and passphrase
|
||||||
|
agePrivKeyStr := ageIdentity.String()
|
||||||
|
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
||||||
agePrivKeyBuffer, agePrivKeyPassphrase)
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Get or derive the long-term private key
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
|
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Get or derive the long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 5: Encrypt long-term private key to the new age unlocker
|
// Step 6: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||||
ltPrivKeyData, ageIdentity.Recipient())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Prepare keychain data
|
// Write encrypted long-term private key
|
||||||
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Prepare keychain data
|
||||||
keychainData := KeychainData{
|
keychainData := KeychainData{
|
||||||
AgePublicKey: ageRecipient,
|
AgePublicKey: ageRecipient,
|
||||||
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
||||||
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
|
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
|
||||||
}
|
}
|
||||||
|
|
||||||
keychainDataBuffer, err := keychainData.encode()
|
keychainDataBytes, err := json.Marshal(keychainData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
|
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a secure buffer for keychain data
|
||||||
|
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
||||||
defer keychainDataBuffer.Destroy()
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
|
// Step 8: Store data in keychain
|
||||||
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
|
// Step 9: Create and write enhanced metadata
|
||||||
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
|
|
||||||
// The data is stored after the unlocker's files are written, and the keychain
|
|
||||||
// item is deleted again if moving the unlocker into place then fails.
|
|
||||||
func writeKeychainUnlocker(
|
|
||||||
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
|
||||||
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
|
||||||
keychainDataBuffer *memguard.LockedBuffer,
|
|
||||||
) (*KeychainUnlocker, error) {
|
|
||||||
// Step 7: Prepare enhanced metadata
|
|
||||||
keychainMetadata := KeychainUnlockerMetadata{
|
keychainMetadata := KeychainUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: keychainUnlockerType,
|
Type: "keychain",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{keychainUnlockerType, macOSFlag},
|
Flags: []string{"keychain", "macos"},
|
||||||
},
|
},
|
||||||
KeychainItemName: keychainItemName,
|
KeychainItemName: keychainItemName,
|
||||||
}
|
}
|
||||||
@@ -512,51 +451,10 @@ func writeKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Write the unlocker's files, the metadata last, then store the
|
if err := afero.WriteFile(fs,
|
||||||
// data in the keychain
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
stored := false
|
metadataBytes, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
|
||||||
metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stored = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil && stored {
|
|
||||||
deleteErr := deleteFromKeychain(keychainItemName)
|
|
||||||
if deleteErr != nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf(
|
|
||||||
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &KeychainUnlocker{
|
return &KeychainUnlocker{
|
||||||
@@ -569,22 +467,117 @@ func writeKeychainUnlocker(
|
|||||||
// checkMacOSAvailable verifies that we're running on macOS
|
// checkMacOSAvailable verifies that we're running on macOS
|
||||||
func checkMacOSAvailable() error {
|
func checkMacOSAvailable() error {
|
||||||
if runtime.GOOS != "darwin" {
|
if runtime.GOOS != "darwin" {
|
||||||
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateKeychainItemName validates that a keychain item name is safe for
|
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
||||||
// command execution
|
|
||||||
func validateKeychainItemName(itemName string) error {
|
func validateKeychainItemName(itemName string) error {
|
||||||
if itemName == "" {
|
if itemName == "" {
|
||||||
return errKeychainItemNameEmpty
|
return fmt.Errorf("keychain item name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keychainItemNameRegex.MatchString(itemName) {
|
if !keychainItemNameRegex.MatchString(itemName) {
|
||||||
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
|
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||||
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
|
item.SetDescription("Secret vault keychain data")
|
||||||
|
item.SetData([]byte(data.String()))
|
||||||
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||||
|
|
||||||
|
// First try to delete any existing item
|
||||||
|
deleteItem := keychain.NewItem()
|
||||||
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
deleteItem.SetAccount(itemName)
|
||||||
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
|
// Add the new item
|
||||||
|
if err := keychain.AddItem(item); err != nil {
|
||||||
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||||
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := keychain.NewItem()
|
||||||
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
query.SetAccount(itemName)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
|
results, err := keychain.QueryItem(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[0].Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||||
|
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
||||||
|
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
||||||
|
func deleteFromKeychain(itemName string) error {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
|
// If the item doesn't exist, that's not an error - the goal is to ensure
|
||||||
|
// the item is gone, and it already being gone satisfies that goal.
|
||||||
|
// This is important for cleaning up unlocker directories when the keychain
|
||||||
|
// item has already been removed (e.g., manually by user, or synced vault
|
||||||
|
// from a different machine).
|
||||||
|
if err == keychain.ErrorItemNotFound {
|
||||||
|
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomPassphrase generates a random passphrase for encrypting the age private key
|
||||||
|
func generateRandomPassphrase(length int) (string, error) {
|
||||||
|
return generateRandomString(length, "0123456789abcdef")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
//go:build darwin && cgo
|
|
||||||
|
|
||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
keychain "github.com/keybase/go-keychain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
|
|
||||||
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
|
|
||||||
|
|
||||||
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
|
||||||
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
|
||||||
if data == nil {
|
|
||||||
return fmt.Errorf("data buffer is nil")
|
|
||||||
}
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := keychain.NewItem()
|
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
item.SetAccount(itemName)
|
|
||||||
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
|
||||||
item.SetDescription("Secret vault keychain data")
|
|
||||||
item.SetData(data.Bytes())
|
|
||||||
item.SetSynchronizable(keychain.SynchronizableNo)
|
|
||||||
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
|
||||||
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
|
||||||
|
|
||||||
// First try to delete any existing item
|
|
||||||
deleteItem := keychain.NewItem()
|
|
||||||
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
deleteItem.SetAccount(itemName)
|
|
||||||
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
|
||||||
|
|
||||||
// Add the new item
|
|
||||||
if err := keychain.AddItem(item); err != nil {
|
|
||||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
|
||||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := keychain.NewItem()
|
|
||||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
query.SetAccount(itemName)
|
|
||||||
query.SetMatchLimit(keychain.MatchLimitOne)
|
|
||||||
query.SetReturnData(true)
|
|
||||||
|
|
||||||
results, err := keychain.QueryItem(query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results[0].Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
|
||||||
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
|
||||||
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
|
||||||
func deleteFromKeychain(itemName string) error {
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := keychain.NewItem()
|
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
item.SetAccount(itemName)
|
|
||||||
|
|
||||||
if err := keychain.DeleteItem(item); err != nil {
|
|
||||||
// If the item doesn't exist, that's not an error - the goal is to ensure
|
|
||||||
// the item is gone, and it already being gone satisfies that goal.
|
|
||||||
// This is important for cleaning up unlocker directories when the keychain
|
|
||||||
// item has already been removed (e.g., manually by user, or synced vault
|
|
||||||
// from a different machine).
|
|
||||||
if err == keychain.ErrorItemNotFound {
|
|
||||||
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//go:build darwin && !cgo
|
|
||||||
|
|
||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
)
|
|
||||||
|
|
||||||
// In a macOS build without cgo, these take the place of the functions in
|
|
||||||
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
|
|
||||||
|
|
||||||
var errKeychainNotSupported = errors.New(
|
|
||||||
"keychain unlockers need a macOS build with cgo")
|
|
||||||
|
|
||||||
// storeInKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
|
|
||||||
return errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func retrieveFromKeychain(_ string) ([]byte, error) {
|
|
||||||
return nil, errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func deleteFromKeychain(_ string) error {
|
|
||||||
return errKeychainNotSupported
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
KeychainItemName string `json:"keychainItemName"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,21 +24,7 @@ type KeychainUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
var errKeychainNotSupported = errors.New(
|
var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||||
"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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIdentity returns an error on non-Darwin platforms
|
// GetIdentity returns an error on non-Darwin platforms
|
||||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
@@ -62,7 +48,7 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID returns the unlocker ID
|
// GetID returns the unlocker ID
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
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
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
@@ -75,9 +61,22 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
return errKeychainNotSupported
|
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
|
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||||
func CreateKeychainUnlocker(
|
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||||
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
|
return nil, errKeychainNotSupported
|
||||||
) (*KeychainUnlocker, error) {
|
}
|
||||||
|
|
||||||
|
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
||||||
|
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
//go:build darwin && cgo
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -37,8 +35,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Test 2: Retrieve data from keychain
|
// Test 2: Retrieve data from keychain
|
||||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve data from keychain")
|
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||||
assert.Equal(t, testData, string(retrievedData),
|
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
|
||||||
"Retrieved data doesn't match stored data")
|
|
||||||
|
|
||||||
// Test 3: Update existing item (store again with different data)
|
// Test 3: Update existing item (store again with different data)
|
||||||
newTestData := "updated-test-data-67890"
|
newTestData := "updated-test-data-67890"
|
||||||
@@ -51,8 +48,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Verify updated data
|
// Verify updated data
|
||||||
retrievedData, err = retrieveFromKeychain(testItemName)
|
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||||
assert.Equal(t, newTestData, string(retrievedData),
|
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
|
||||||
"Retrieved data doesn't match updated data")
|
|
||||||
|
|
||||||
// Test 4: Delete from keychain
|
// Test 4: Delete from keychain
|
||||||
err = deleteFromKeychain(testItemName)
|
err = deleteFromKeychain(testItemName)
|
||||||
@@ -97,8 +93,7 @@ func TestKeychainInvalidItemName(t *testing.T) {
|
|||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
err := storeInKeychain(name, testData)
|
err := storeInKeychain(name, testData)
|
||||||
assert.Error(t, err, "Expected error for invalid name: %s", name)
|
assert.Error(t, err, "Expected error for invalid name: %s", name)
|
||||||
assert.Contains(t, err.Error(), "invalid keychain item name",
|
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
|
||||||
"Error should mention invalid name for: %s", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test valid names (should not error on validation)
|
// Test valid names (should not error on validation)
|
||||||
@@ -185,30 +180,5 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
|||||||
// This is important for cleaning up unlocker directories when the keychain item
|
// This is important for cleaning up unlocker directories when the keychain item
|
||||||
// has already been removed (e.g., manually by user, or on a different machine)
|
// has already been removed (e.g., manually by user, or on a different machine)
|
||||||
err := deleteFromKeychain(testItemName)
|
err := deleteFromKeychain(testItemName)
|
||||||
assert.NoError(t, err,
|
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error")
|
||||||
"Deleting non-existent keychain item should not return an error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
|
||||||
// unlocker into place fail after its data is stored in the keychain: the
|
|
||||||
// keychain item must be deleted again.
|
|
||||||
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
|
||||||
testItemName := "test-secret-keychain-unlocker-cleanup"
|
|
||||||
_ = deleteFromKeychain(testItemName)
|
|
||||||
|
|
||||||
// Moving the unlocker into a read-only directory fails
|
|
||||||
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
|
||||||
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
|
||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
|
||||||
defer testBuffer.Destroy()
|
|
||||||
|
|
||||||
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
|
||||||
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
|
||||||
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
|
||||||
require.ErrorIs(t, err, os.ErrPermission,
|
|
||||||
"moving the unlocker into place should fail")
|
|
||||||
|
|
||||||
_, err = retrieveFromKeychain(testItemName)
|
|
||||||
assert.Error(t, err, "keychain item left behind")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,145 +13,29 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonic is the standard BIP39 test vector mnemonic.
|
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||||
//
|
// This test uses real filesystem
|
||||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
if os.Getenv("CI") == "true" {
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
t.Log("Running in CI environment with real filesystem")
|
||||||
"abandon abandon abandon abandon abandon about"
|
}
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
// Create a temporary directory for our tests
|
||||||
// destroyed when the test ends.
|
tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to write public key: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
|
||||||
|
|
||||||
// Verify the file exists
|
// Use the real filesystem
|
||||||
exists, err := afero.Exists(fs, pubKeyPath)
|
fs := afero.NewOsFs()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
// Test data
|
||||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
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,
|
// Create the directory structure
|
||||||
// writes it, and verifies it exists.
|
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||||
func writeTestPrivateKey(
|
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||||
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 {
|
|
||||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,78 +54,117 @@ func newTestPassphraseUnlocker(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to generate age identity: %v", err)
|
t.Fatalf("Failed to generate age identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return unlocker, ageIdentity, unlockerDir
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share real-FS state, 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()
|
agePrivateKey := ageIdentity.String()
|
||||||
agePublicKey := ageIdentity.Recipient().String()
|
agePublicKey := ageIdentity.Recipient().String()
|
||||||
|
|
||||||
// Test writing public key
|
// Test writing public key
|
||||||
t.Run("WritePublicKey", func(t *testing.T) {
|
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
|
// Test encrypting private key with passphrase
|
||||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
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
|
// Test writing long-term key
|
||||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
// Set test environment variable (cleaned up automatically)
|
||||||
defer passphrase.Destroy()
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
unlocker.Passphrase = passphrase
|
// Test getting identity from environment variable
|
||||||
|
t.Run("GetIdentityFromEnv", func(t *testing.T) {
|
||||||
// Test getting identity with the passphrase the unlocker was given,
|
|
||||||
// twice: using it must leave it intact for the next use
|
|
||||||
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
|
|
||||||
for range 2 {
|
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get identity with passphrase: %v", err)
|
t.Fatalf("Failed to get identity from env: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the identity matches what we expect
|
// Verify the identity matches what we expect
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
|
|
||||||
actualPubKey := identity.Recipient().String()
|
actualPubKey := identity.Recipient().String()
|
||||||
if actualPubKey != expectedPubKey {
|
if actualPubKey != expectedPubKey {
|
||||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||||
expectedPubKey, actualPubKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
unlocker.Passphrase = nil
|
// Unset the environment variable to test interactive prompt
|
||||||
|
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||||
|
|
||||||
// Test getting identity from prompt (this would require mocking the
|
// Test getting identity from prompt (this would require mocking the prompt)
|
||||||
// prompt). For real integration tests, we'd need a way to mock the
|
// For real integration tests, we'd need to provide a way to mock the passphrase input
|
||||||
// passphrase input. Here we just verify the error is what we expect
|
// Here we'll just verify the error is what we expect when no passphrase is available
|
||||||
// when no passphrase is available.
|
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||||
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
|
|
||||||
// This should fail since we're not in an interactive terminal
|
// This should fail since we're not in an interactive terminal
|
||||||
_, err := unlocker.GetIdentity()
|
_, err := unlocker.GetIdentity()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Should have failed to get identity without a passphrase")
|
t.Errorf("Should have failed to get identity without passphrase env var")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,7 +180,6 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
t.Errorf("Unlocker directory should not exist after removal")
|
t.Errorf("Unlocker directory should not exist after removal")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package secret
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -18,15 +19,37 @@ type PassphraseUnlocker struct {
|
|||||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
// getPassphrase retrieves the passphrase from memory, environment, or user input
|
||||||
func NewPassphraseUnlocker(
|
// Returns a LockedBuffer for secure memory handling
|
||||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||||
) *PassphraseUnlocker {
|
// First check if we already have the passphrase
|
||||||
return &PassphraseUnlocker{
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
Directory: directory,
|
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||||
Metadata: metadata,
|
// Return a copy of the passphrase buffer
|
||||||
fs: fs,
|
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Debug("No passphrase in memory, checking environment")
|
||||||
|
// Check environment variable for passphrase
|
||||||
|
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||||
|
if passphraseStr != "" {
|
||||||
|
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||||
|
// Convert to secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("No passphrase in environment, prompting user")
|
||||||
|
// Prompt for passphrase
|
||||||
|
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
||||||
@@ -48,8 +71,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read passphrase unlocker private key",
|
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
||||||
"error", err, "path", unlockerPrivPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,8 +86,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
// Decrypt the unlocker private key with passphrase
|
// Decrypt the unlocker private key with passphrase
|
||||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt unlocker private key",
|
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||||
"error", err, "unlocker_id", p.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +135,7 @@ func (p *PassphraseUnlocker) GetID() string {
|
|||||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||||
createdAt := p.Metadata.CreatedAt
|
createdAt := p.Metadata.CreatedAt
|
||||||
|
|
||||||
return 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
|
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||||
@@ -126,36 +147,20 @@ func (p *PassphraseUnlocker) Remove() error {
|
|||||||
|
|
||||||
// For passphrase unlockers, we just need to remove the directory
|
// For passphrase unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// No external resources (like keychain items) to clean up
|
||||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
|
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||||
// the passphrase. The caller must destroy the returned buffer.
|
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
|
||||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
return &PassphraseUnlocker{
|
||||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
Directory: directory,
|
||||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
Metadata: metadata,
|
||||||
// Not NewBufferFromBytes, which would wipe p.Passphrase
|
fs: fs,
|
||||||
passphrase := memguard.NewBuffer(p.Passphrase.Size())
|
|
||||||
passphrase.Copy(p.Passphrase.Bytes())
|
|
||||||
|
|
||||||
return passphrase, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("No passphrase in memory, 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
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
|
|||||||
+122
-254
@@ -4,9 +4,7 @@ package secret_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -24,24 +22,23 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pgpUnlockerType is the type of a PGP unlocker.
|
// Register vault with secret package for testing
|
||||||
const pgpUnlockerType = "pgp"
|
func init() {
|
||||||
|
// Register the vault.GetCurrentVault function with the secret package
|
||||||
var errNilDataBuffer = errors.New("data buffer is nil")
|
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||||
|
return vault.GetCurrentVault(fs, stateDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||||
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Create GPG config file for non-interactive operation
|
// Create GPG config file for non-interactive operation
|
||||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||||
gpgConfContent := `batch
|
gpgConfContent := `batch
|
||||||
no-tty
|
no-tty
|
||||||
pinentry-mode loopback
|
pinentry-mode loopback
|
||||||
`
|
`
|
||||||
|
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
|
||||||
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,15 +47,11 @@ pinentry-mode loopback
|
|||||||
origDecryptFunc := secret.GPGDecryptFunc
|
origDecryptFunc := secret.GPGDecryptFunc
|
||||||
|
|
||||||
// Set custom GPG functions for this test
|
// Set custom GPG functions for this test
|
||||||
secret.GPGEncryptFunc = func(
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
data *memguard.LockedBuffer, keyID string,
|
|
||||||
) ([]byte, error) {
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, errNilDataBuffer
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
}
|
}
|
||||||
|
cmd := exec.Command("gpg",
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
|
||||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -70,13 +63,11 @@ pinentry-mode loopback
|
|||||||
"-r", keyID)
|
"-r", keyID)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(data.Bytes())
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||||
|
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +75,7 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
cmd := exec.Command("gpg",
|
||||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -95,13 +85,11 @@ pinentry-mode loopback
|
|||||||
"--decrypt")
|
"--decrypt")
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(encryptedData)
|
cmd.Stdin = bytes.NewReader(encryptedData)
|
||||||
|
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,24 +105,20 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
||||||
func runGPGWithPassphrase(
|
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
|
||||||
ctx context.Context,
|
cmdArgs := []string{
|
||||||
gnupgHome, passphrase string, args []string, input io.Reader,
|
|
||||||
) ([]byte, error) {
|
|
||||||
cmdArgs := append([]string{
|
|
||||||
"--homedir=" + gnupgHome,
|
"--homedir=" + gnupgHome,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
"--pinentry-mode", "loopback",
|
"--pinentry-mode", "loopback",
|
||||||
"--passphrase", passphrase,
|
"--passphrase", passphrase,
|
||||||
}, args...)
|
}
|
||||||
|
cmdArgs = append(cmdArgs, args...)
|
||||||
|
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
cmd := exec.Command("gpg", cmdArgs...)
|
||||||
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
|
|
||||||
cmd.Stdin = input
|
cmd.Stdin = input
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@@ -146,96 +130,14 @@ func runGPGWithPassphrase(
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateTestGPGKey generates a GPG key protected by passphrase in
|
|
||||||
// gnupgHomeDir and returns its key ID and fingerprint.
|
|
||||||
func generateTestGPGKey(
|
|
||||||
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Create GPG batch file for key generation
|
|
||||||
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
|
||||||
batchContent := `%echo Generating a test key
|
|
||||||
Key-Type: RSA
|
|
||||||
Key-Length: 2048
|
|
||||||
Name-Real: Test User
|
|
||||||
Name-Email: test@example.com
|
|
||||||
Expire-Date: 0
|
|
||||||
Passphrase: ` + passphrase + `
|
|
||||||
%commit
|
|
||||||
%echo Key generation completed
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write batch file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate GPG key with batch mode
|
|
||||||
t.Log("Generating GPG key...")
|
|
||||||
|
|
||||||
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
|
||||||
[]string{"--gen-key", batchFile}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to generate GPG key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("GPG key generated successfully")
|
|
||||||
|
|
||||||
// Get the key ID and fingerprint
|
|
||||||
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
|
||||||
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list GPG keys: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output to get key ID and fingerprint
|
|
||||||
var keyID, fingerprint string
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(string(output), "\n") {
|
|
||||||
if strings.HasPrefix(line, "sec:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
keyID = fields[4]
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(line, "fpr:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 10 && fields[9] != "" {
|
|
||||||
fingerprint = fields[9]
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keyID == "" {
|
|
||||||
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fingerprint == "" {
|
|
||||||
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Generated GPG key ID: %s", keyID)
|
|
||||||
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
|
||||||
|
|
||||||
return keyID, fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
||||||
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||||
// Check if gpg is available
|
// Check if gpg is available
|
||||||
_, err := exec.LookPath("gpg")
|
if _, err := exec.LookPath("gpg"); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
||||||
// Continue anyway to test what we can
|
// Continue anyway to test what we can
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary directory for our tests. Not t.TempDir: its longer
|
// Create a temporary directory for our tests
|
||||||
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
|
|
||||||
// macOS sets on socket paths.
|
|
||||||
//
|
|
||||||
//nolint:usetesting // see the comment above
|
|
||||||
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
@@ -244,9 +146,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Create a temporary GNUPGHOME
|
// Create a temporary GNUPGHOME
|
||||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||||
|
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
|
||||||
err = os.MkdirAll(gnupgHomeDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +159,64 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Setup non-interactive GPG with custom functions
|
// Setup non-interactive GPG with custom functions
|
||||||
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
||||||
|
|
||||||
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
|
// Create GPG batch file for key generation
|
||||||
|
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
||||||
|
batchContent := `%echo Generating a test key
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 2048
|
||||||
|
Name-Real: Test User
|
||||||
|
Name-Email: test@example.com
|
||||||
|
Expire-Date: 0
|
||||||
|
Passphrase: ` + testPassphrase + `
|
||||||
|
%commit
|
||||||
|
%echo Key generation completed
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write batch file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GPG key with batch mode
|
||||||
|
t.Log("Generating GPG key...")
|
||||||
|
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
||||||
|
[]string{"--gen-key", batchFile}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate GPG key: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("GPG key generated successfully")
|
||||||
|
|
||||||
|
// Get the key ID and fingerprint
|
||||||
|
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
||||||
|
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list GPG keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to get key ID and fingerprint
|
||||||
|
var keyID, fingerprint string
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "sec:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
keyID = fields[4]
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, "fpr:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[9] != "" {
|
||||||
|
fingerprint = fields[9]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyID == "" {
|
||||||
|
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
||||||
|
}
|
||||||
|
if fingerprint == "" {
|
||||||
|
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
||||||
|
}
|
||||||
|
t.Logf("Generated GPG key ID: %s", keyID)
|
||||||
|
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
||||||
|
|
||||||
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
||||||
t.Setenv("GPG_AGENT_INFO", "")
|
t.Setenv("GPG_AGENT_INFO", "")
|
||||||
@@ -267,7 +224,11 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||||
|
|
||||||
// Set up vault structure for testing
|
// Set up vault structure for testing
|
||||||
@@ -276,61 +237,14 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Test creation of a PGP unlock key through a vault
|
// Test creation of a PGP unlock key through a vault
|
||||||
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
||||||
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set up key directory for individual tests
|
|
||||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
|
||||||
|
|
||||||
err = os.MkdirAll(unlockerDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up test metadata
|
|
||||||
metadata := secret.UnlockerMetadata{
|
|
||||||
Type: pgpUnlockerType,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
Flags: []string{"gpg", "encrypted"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a PGP unlocker for the remaining tests
|
|
||||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
||||||
|
|
||||||
// Test getting GPG key ID
|
|
||||||
t.Run("GetGPGKeyID", func(t *testing.T) {
|
|
||||||
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test getting identity from PGP unlocker
|
|
||||||
t.Run("GetIdentity", func(t *testing.T) {
|
|
||||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test removing the unlocker
|
|
||||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
|
||||||
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
|
|
||||||
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
|
|
||||||
// and metadata.
|
|
||||||
func testCreatePGPUnlocker(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Set a limited test timeout to avoid hanging
|
// Set a limited test timeout to avoid hanging
|
||||||
timer := time.AfterFunc(30*time.Second, func() {
|
timer := time.AfterFunc(30*time.Second, func() {
|
||||||
t.Fatalf("Test timed out after 30 seconds")
|
t.Fatalf("Test timed out after 30 seconds")
|
||||||
})
|
})
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Create a test vault directory structure
|
// Create a test vault directory structure
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -355,10 +269,7 @@ func testCreatePGPUnlocker(
|
|||||||
|
|
||||||
// Write long-term public key
|
// Write long-term public key
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, ltPubKeyPath,
|
|
||||||
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +279,6 @@ func testCreatePGPUnlocker(
|
|||||||
// Create a passphrase unlocker first (to have current unlocker)
|
// Create a passphrase unlocker first (to have current unlocker)
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
@@ -380,8 +290,7 @@ func testCreatePGPUnlocker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(
|
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
|
||||||
fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -392,91 +301,63 @@ func testCreatePGPUnlocker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the key has the correct type
|
// Check if the key has the correct type
|
||||||
if pgpUnlocker.GetType() != pgpUnlockerType {
|
if pgpUnlocker.GetType() != "pgp" {
|
||||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the key ID includes the GPG fingerprint
|
// Check if the key ID includes the GPG fingerprint
|
||||||
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
||||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'",
|
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
||||||
pgpUnlocker.GetID(), fingerprint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
|
|
||||||
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
|
|
||||||
// its files.
|
|
||||||
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Check if the key directory exists
|
// Check if the key directory exists
|
||||||
|
unlockerDir := pgpUnlocker.GetDirectory()
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keyExists {
|
if !keyExists {
|
||||||
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if required files exist
|
// Check if required files exist
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
|
||||||
recipientExists, err := afero.Exists(fs, recipientPath)
|
recipientExists, err := afero.Exists(fs, recipientPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !recipientExists {
|
if !recipientExists {
|
||||||
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
|
||||||
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if private key file exists: %v", err)
|
t.Fatalf("Failed to check if private key file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !privKeyExists {
|
if !privKeyExists {
|
||||||
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataExists, err := afero.Exists(fs, metadataPath)
|
metadataExists, err := afero.Exists(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !metadataExists {
|
if !metadataExists {
|
||||||
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
|
||||||
longtermExists, err := afero.Exists(fs, longtermPath)
|
longtermExists, err := afero.Exists(fs, longtermPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !longtermExists {
|
if !longtermExists {
|
||||||
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
|
|
||||||
// unlockerDir names its type and the GPG key by fingerprint.
|
|
||||||
func checkPGPUnlockerMetadata(
|
|
||||||
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Read and verify metadata
|
// Read and verify metadata
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read metadata: %v", err)
|
t.Fatalf("Failed to read metadata: %v", err)
|
||||||
@@ -490,32 +371,40 @@ func checkPGPUnlockerMetadata(
|
|||||||
GPGKeyID string `json:"gpgKeyId"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse metadata: %v", err)
|
t.Fatalf("Failed to parse metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Type != pgpUnlockerType {
|
if metadata.Type != "pgp" {
|
||||||
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.GPGKeyID != fingerprint {
|
if metadata.GPGKeyID != fingerprint {
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
|
// Set up key directory for individual tests
|
||||||
// into unlockerDir and checks that unlocker reads it back.
|
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||||
func testGetGPGKeyID(
|
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||||
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||||
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
|
}
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Set up test metadata
|
||||||
|
metadata := secret.UnlockerMetadata{
|
||||||
|
Type: "pgp",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Flags: []string{"gpg", "encrypted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a PGP unlocker for the remaining tests
|
||||||
|
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||||
|
|
||||||
|
// Test getting GPG key ID
|
||||||
|
t.Run("GetGPGKeyID", func(t *testing.T) {
|
||||||
// Create PGP metadata with GPG key ID
|
// Create PGP metadata with GPG key ID
|
||||||
type PGPUnlockerMetadata struct {
|
type PGPUnlockerMetadata struct {
|
||||||
secret.UnlockerMetadata
|
secret.UnlockerMetadata
|
||||||
|
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,15 +414,12 @@ func testGetGPGKeyID(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write metadata file
|
// Write metadata file
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to marshal metadata: %v", err)
|
t.Fatalf("Failed to marshal metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write metadata: %v", err)
|
t.Fatalf("Failed to write metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,16 +433,10 @@ func testGetGPGKeyID(
|
|||||||
if retrievedKeyID != fingerprint {
|
if retrievedKeyID != fingerprint {
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
|
||||||
// keyID into unlockerDir and checks that unlocker decrypts it.
|
|
||||||
func testPGPUnlockerGetIdentity(
|
|
||||||
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
|
||||||
unlockerDir, keyID string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test getting identity from PGP unlocker
|
||||||
|
t.Run("GetIdentity", func(t *testing.T) {
|
||||||
// Generate an age identity for testing
|
// Generate an age identity for testing
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -565,17 +445,13 @@ func testPGPUnlockerGetIdentity(
|
|||||||
|
|
||||||
// Write the recipient
|
// Write the recipient
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, recipientPath,
|
|
||||||
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write recipient: %v", err)
|
t.Fatalf("Failed to write recipient: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPG encrypt the private key using our custom encrypt function
|
// GPG encrypt the private key using our custom encrypt function
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
defer privKeyBuffer.Destroy()
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||||
@@ -583,9 +459,7 @@ func testPGPUnlockerGetIdentity(
|
|||||||
|
|
||||||
// Write the encrypted data to a file
|
// Write the encrypted data to a file
|
||||||
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,24 +472,18 @@ func testPGPUnlockerGetIdentity(
|
|||||||
// Verify the identity matches
|
// Verify the identity matches
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
actualPubKey := identity.Recipient().String()
|
actualPubKey := identity.Recipient().String()
|
||||||
|
|
||||||
if actualPubKey != expectedPubKey {
|
if actualPubKey != expectedPubKey {
|
||||||
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
|
|
||||||
func testRemovePGPUnlocker(
|
|
||||||
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test removing the unlocker
|
||||||
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||||
// Ensure unlocker directory exists before removal
|
// Ensure unlocker directory exists before removal
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keyExists {
|
if !keyExists {
|
||||||
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
@@ -631,8 +499,8 @@ func testRemovePGPUnlocker(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyExists {
|
if keyExists {
|
||||||
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-193
@@ -1,9 +1,7 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,28 +16,17 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"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
|
// Variables to allow overriding in tests
|
||||||
var (
|
var (
|
||||||
// GPGEncryptFunc is the function used for GPG encryption
|
// GPGEncryptFunc is the function used for GPG encryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
//nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
GPGEncryptFunc func(
|
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
|
||||||
data *memguard.LockedBuffer, keyID string,
|
|
||||||
) ([]byte, error) = gpgEncryptDefault
|
|
||||||
|
|
||||||
// GPGDecryptFunc is the function used for GPG decryption
|
// GPGDecryptFunc is the function used for GPG decryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
//nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
GPGDecryptFunc func(
|
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||||
encryptedData []byte,
|
|
||||||
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
|
||||||
|
|
||||||
// gpgKeyIDRegex validates GPG key IDs
|
// gpgKeyIDRegex validates GPG key IDs
|
||||||
// Allows either:
|
// Allows either:
|
||||||
@@ -58,7 +45,6 @@ var (
|
|||||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||||
type PGPUnlockerMetadata struct {
|
type PGPUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
// GPG key ID used for encryption
|
// GPG key ID used for encryption
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
@@ -70,17 +56,6 @@ type PGPUnlocker struct {
|
|||||||
fs afero.Fs
|
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
|
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
||||||
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
DebugWith("Getting PGP unlocker identity",
|
DebugWith("Getting PGP unlocker identity",
|
||||||
@@ -94,8 +69,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read PGP-encrypted age private key",
|
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||||
"error", err, "path", agePrivKeyPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -107,11 +81,9 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Step 2: Decrypt the age private key using GPG
|
// Step 2: Decrypt the age private key using GPG
|
||||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with GPG",
|
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
||||||
"error", err, "unlocker_id", p.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
||||||
}
|
}
|
||||||
@@ -124,7 +96,6 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Step 3: Parse the decrypted age private key
|
// Step 3: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||||
@@ -155,139 +126,153 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||||
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
|
||||||
// directory and returns "pgp-unknown", so listing the other unlockers
|
|
||||||
// still works.
|
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: pgp-<keyid>
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
// The vault metadata is corrupt - this is a fatal error
|
||||||
"directory", p.Directory, "error", err)
|
// We cannot continue with a fallback ID as that would mask data corruption
|
||||||
|
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||||
return "pgp-unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "pgp-" + gpgKeyID
|
return fmt.Sprintf("pgp-%s", gpgKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
func (p *PGPUnlocker) Remove() error {
|
func (p *PGPUnlocker) Remove() error {
|
||||||
// For PGP unlockers, we just need to remove the directory
|
// For PGP unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// No external resources (like keychain items) to clean up
|
||||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// GetGPGKeyID returns the GPG key ID from metadata
|
||||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||||
// Load the metadata
|
// Load the metadata
|
||||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pgpMetadata PGPUnlockerMetadata
|
var pgpMetadata PGPUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
|
||||||
err = json.Unmarshal(metadataData, &pgpMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pgpMetadata.GPGKeyID == "" {
|
|
||||||
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
return pgpMetadata.GPGKeyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
|
||||||
// based on hostname and time
|
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
// Format: hostname-pgp-YYYY-MM-DD
|
||||||
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgpUnlockerDir returns the current vault and the directory in it for a
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||||
// new PGP unlocker, named after the host and the time.
|
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
|
||||||
//
|
// Check if GPG is available
|
||||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
if err := checkGPGAvailable(); err != nil {
|
||||||
func pgpUnlockerDir(
|
return nil, err
|
||||||
fs afero.Fs, stateDir string,
|
}
|
||||||
) (VaultInterface, string, error) {
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
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 time
|
// Generate the unlocker name based on hostname and date
|
||||||
unlockerName, err := generatePGPUnlockerName()
|
unlockerName, err := generatePGPUnlockerName()
|
||||||
if err != nil {
|
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()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
||||||
}
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
|
||||||
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
|
||||||
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
|
|
||||||
// Everything that can fail short of writing a file is done before anything
|
|
||||||
// is written, and the files are written through WriteDir, so a failure
|
|
||||||
// leaves no partial unlocker. The long-term key comes from mnemonic when it
|
|
||||||
// is not nil, else from the current unlocker, which is given passphrase when
|
|
||||||
// it is a passphrase unlocker.
|
|
||||||
func CreatePGPUnlocker(
|
|
||||||
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*PGPUnlocker, error) {
|
|
||||||
err := checkGPGAvailable()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
|
||||||
vault.SetMnemonic(mnemonic)
|
|
||||||
vault.SetUnlockPassphrase(passphrase)
|
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
// Step 2: Store age recipient as plaintext
|
||||||
// keypair's private key to the GPG key
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
vault, ageIdentity, gpgKeyID)
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get or derive the long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
|
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write encrypted long-term private key
|
||||||
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Encrypt age private key to the GPG key ID
|
||||||
|
// Use memguard to protect the private key in memory
|
||||||
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: "pgp",
|
Type: "pgp",
|
||||||
@@ -302,13 +287,10 @@ func CreatePGPUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Write the unlocker's files, the metadata last
|
if err := afero.WriteFile(fs,
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
metadataBytes, FilePerms); err != nil {
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PGPUnlocker{
|
return &PGPUnlocker{
|
||||||
@@ -318,79 +300,14 @@ func CreatePGPUnlocker(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
|
|
||||||
// to the new PGP unlocker's age keypair, and that keypair's private key
|
|
||||||
// encrypted to the GPG key gpgKeyID.
|
|
||||||
func encryptPGPUnlockerKeys(
|
|
||||||
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
|
||||||
) ([]byte, []byte, error) {
|
|
||||||
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
|
|
||||||
defer ltPrivKeyData.Destroy()
|
|
||||||
|
|
||||||
encryptedLtPrivKey, err := EncryptToRecipient(
|
|
||||||
ltPrivKeyData, ageIdentity.Recipient())
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to encrypt age private key with GPG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return encryptedLtPrivKey, encryptedAgePrivKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
|
|
||||||
// metadata last.
|
|
||||||
func writePGPUnlockerFiles(
|
|
||||||
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
|
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
|
|
||||||
) error {
|
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
|
|
||||||
[]byte(ageRecipient.String()))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs,
|
|
||||||
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||||
func validateGPGKeyID(keyID string) error {
|
func validateGPGKeyID(keyID string) error {
|
||||||
if keyID == "" {
|
if keyID == "" {
|
||||||
return errGPGKeyIDEmpty
|
return fmt.Errorf("GPG key ID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||||
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -398,24 +315,22 @@ func validateGPGKeyID(keyID string) error {
|
|||||||
|
|
||||||
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||||
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||||
err := validateGPGKeyID(keyID)
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use GPG to get the full fingerprint for the key
|
// Use GPG to get the full fingerprint for the key
|
||||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||||
context.Background(),
|
|
||||||
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||||
)
|
)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the output to extract the fingerprint
|
// 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:") {
|
if strings.HasPrefix(line, "fpr:") {
|
||||||
fields := strings.Split(line, ":")
|
fields := strings.Split(line, ":")
|
||||||
if len(fields) >= 10 && fields[9] != "" {
|
if len(fields) >= 10 && fields[9] != "" {
|
||||||
@@ -424,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
|
// checkGPGAvailable verifies that GPG is available
|
||||||
func checkGPGAvailable() error {
|
func checkGPGAvailable() error {
|
||||||
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
cmd := exec.Command("gpg", "--version")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
err := cmd.Run()
|
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
|
||||||
err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -444,16 +355,13 @@ func checkGPGAvailable() error {
|
|||||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, errNilDataBuffer
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
}
|
}
|
||||||
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
err := validateGPGKeyID(keyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||||
context.Background(),
|
|
||||||
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||||
)
|
)
|
||||||
cmd.Stdin = strings.NewReader(data.String())
|
cmd.Stdin = strings.NewReader(data.String())
|
||||||
@@ -468,7 +376,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
|||||||
|
|
||||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||||
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
|
|
||||||
const (
|
|
||||||
testGPGKeyID = "0123456789ABCDEF"
|
|
||||||
testGPGFingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeGPGScript is a gpg for which `gpg --version` succeeds and anything
|
|
||||||
// else fails.
|
|
||||||
const fakeGPGScript = `#!/bin/sh
|
|
||||||
[ "$*" = --version ]
|
|
||||||
`
|
|
||||||
|
|
||||||
// installFakeGPG makes fakeGPGScript the only gpg on PATH for the test.
|
|
||||||
func installFakeGPG(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
//nolint:gosec // G306: the script must be executable
|
|
||||||
err := os.WriteFile(filepath.Join(dir, "gpg"), []byte(fakeGPGScript), 0o700)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
|
|
||||||
// getting the vault's long-term key, which used to come after part of the
|
|
||||||
// unlocker was written, and asserts that nothing is written. Getting the key
|
|
||||||
// fails because there is no mnemonic and no current unlocker.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
|
||||||
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
|
||||||
installFakeGPG(t)
|
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
|
||||||
t.Errorf("changed %s", path)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err = secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
|
|
||||||
// the other, so on the same host and day, and checks that the second gets a
|
|
||||||
// directory of its own and leaves the first one's files as they were.
|
|
||||||
// CreatePGPUnlocker does not check whether the GPG key already has an
|
|
||||||
// unlocker, so the test key serves for both.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
|
||||||
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
|
||||||
installFakeGPG(t)
|
|
||||||
|
|
||||||
original := secret.GPGEncryptFunc
|
|
||||||
|
|
||||||
t.Cleanup(func() { secret.GPGEncryptFunc = original })
|
|
||||||
|
|
||||||
// Stands in for gpg, which the test does not have: "encrypts" by copying
|
|
||||||
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
|
|
||||||
return []byte(data.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
first, err := secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
firstFiles := dirFiles(t, fs, first.GetDirectory())
|
|
||||||
|
|
||||||
second, err := secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
|
||||||
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
|
||||||
}
|
|
||||||
+103
-171
@@ -2,9 +2,9 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,18 +15,6 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"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
|
// VaultInterface defines the interface that vault implementations must satisfy
|
||||||
type VaultInterface interface {
|
type VaultInterface interface {
|
||||||
GetDirectory() (string, error)
|
GetDirectory() (string, error)
|
||||||
@@ -34,14 +22,7 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
|
||||||
// mnemonic to derive the long-term key from, and the passphrase for a
|
|
||||||
// current passphrase unlocker; nil for none.
|
|
||||||
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
|
||||||
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
|
||||||
CreatePassphraseUnlocker(
|
|
||||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret represents a secret in a vault
|
// Secret represents a secret in a vault
|
||||||
@@ -81,12 +62,8 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the current version's value, with the
|
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||||
// vault's long-term key derived from mnemonic when it is not nil, else
|
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||||
// obtained through unlocker
|
|
||||||
func (s *Secret) GetValue(
|
|
||||||
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
DebugWith("Getting secret value",
|
DebugWith("Getting secret value",
|
||||||
slog.String("secret_name", s.Name),
|
slog.String("secret_name", s.Name),
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
slog.String("vault_name", s.vault.GetName()),
|
||||||
@@ -95,17 +72,14 @@ func (s *Secret) GetValue(
|
|||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
exists, err := s.Exists()
|
exists, err := s.Exists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to check if secret exists during GetValue",
|
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
||||||
"error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
Debug("Secret not found during GetValue",
|
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||||
"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)
|
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||||
@@ -121,8 +95,52 @@ func (s *Secret) GetValue(
|
|||||||
// Create version object
|
// Create version object
|
||||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||||
|
|
||||||
if mnemonic != nil {
|
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||||
return s.getValueViaMnemonic(version, mnemonic.String())
|
if envMnemonic := os.Getenv(EnvMnemonic); 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)
|
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||||
@@ -131,12 +149,51 @@ func (s *Secret) GetValue(
|
|||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||||
|
|
||||||
return nil, 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 {
|
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",
|
DebugWith("Successfully obtained vault's long-term key",
|
||||||
@@ -150,8 +207,7 @@ func (s *Secret) GetValue(
|
|||||||
|
|
||||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||||
func (s *Secret) LoadMetadata() error {
|
func (s *Secret) LoadMetadata() error {
|
||||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||||
"secret_name", s.Name)
|
|
||||||
// For backward compatibility, we'll populate with basic info
|
// For backward compatibility, we'll populate with basic info
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s.Metadata = Metadata{
|
s.Metadata = Metadata{
|
||||||
@@ -171,10 +227,9 @@ func (s *Secret) GetMetadata() Metadata {
|
|||||||
|
|
||||||
// GetEncryptedData is deprecated - data is now stored in versions
|
// GetEncryptedData is deprecated - data is now stored in versions
|
||||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||||
Debug("GetEncryptedData called but is deprecated in versioned model",
|
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||||
"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
|
// Exists checks if the secret exists on disk
|
||||||
@@ -187,8 +242,7 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
// Check if the secret directory exists and has a current symlink
|
// Check if the secret directory exists and has a current symlink
|
||||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to check secret directory existence",
|
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
||||||
"error", err, "secret_dir", s.Directory)
|
|
||||||
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -215,134 +269,14 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getValueViaMnemonic derives the vault's long-term key from the
|
|
||||||
// mnemonic and decrypts the version value with it.
|
|
||||||
func (s *Secret) getValueViaMnemonic(
|
|
||||||
version *Version, mnemonic string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
Debug("Using mnemonic 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(mnemonic, 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
|
// GetCurrentVault gets the current vault from the file system
|
||||||
// This function is a wrapper around the actual implementation in the vault package
|
// This function is a wrapper around the actual implementation in the vault package
|
||||||
// and exists to break the import cycle.
|
// 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) {
|
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||||
// This is a forward declaration. The actual implementation is provided
|
// This is a forward declaration. The actual implementation is provided
|
||||||
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
||||||
if getCurrentVaultFunc == nil {
|
if getCurrentVaultFunc == nil {
|
||||||
return nil, errGetCurrentVaultNotRegistered
|
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
return getCurrentVaultFunc(fs, stateDir)
|
return getCurrentVaultFunc(fs, stateDir)
|
||||||
@@ -354,10 +288,8 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
|||||||
//nolint:gochecknoglobals // Required to break import cycle
|
//nolint:gochecknoglobals // Required to break import cycle
|
||||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||||
|
|
||||||
// RegisterGetCurrentVaultFunc allows the vault package to register its
|
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
||||||
// implementation of GetCurrentVault to break the import cycle
|
// of GetCurrentVault to break the import cycle
|
||||||
func RegisterGetCurrentVaultFunc(
|
func RegisterGetCurrentVaultFunc(fn func(fs afero.Fs, stateDir string) (VaultInterface, error)) {
|
||||||
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
|
|
||||||
) {
|
|
||||||
getCurrentVaultFunc = fn
|
getCurrentVaultFunc = fn
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-176
@@ -1,9 +1,7 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,24 +14,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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("mock vault has no mnemonic")
|
|
||||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockVault is a test implementation of the VaultInterface
|
// MockVault is a test implementation of the VaultInterface
|
||||||
type MockVault struct {
|
type MockVault struct {
|
||||||
name string
|
name string
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
directory string
|
directory string
|
||||||
derivationIndex uint32
|
derivationIndex uint32
|
||||||
mnemonic *memguard.LockedBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) GetDirectory() (string, error) {
|
func (m *MockVault) GetDirectory() (string, error) {
|
||||||
@@ -44,18 +30,14 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
// Create secret directory with proper storage name conversion
|
// Create secret directory with proper storage name conversion
|
||||||
storageName := strings.ReplaceAll(name, "/", "%")
|
storageName := strings.ReplaceAll(name, "/", "%")
|
||||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||||
|
if err := m.fs.MkdirAll(secretDir, 0o700); err != nil {
|
||||||
err := m.fs.MkdirAll(secretDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create version directory with proper path
|
// Create version directory with proper path
|
||||||
versionName := "20240101.001" // Use a fixed version name for testing
|
versionName := "20240101.001" // Use a fixed version name for testing
|
||||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||||
|
if err := m.fs.MkdirAll(versionDir, 0o700); err != nil {
|
||||||
err = m.fs.MkdirAll(versionDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,74 +45,24 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
||||||
|
|
||||||
// Derive long-term key using the vault's derivation index
|
// Derive long-term key using the vault's derivation index
|
||||||
if m.mnemonic == nil {
|
mnemonic := os.Getenv(EnvMnemonic)
|
||||||
return errMnemonicNotSet
|
if mnemonic == "" {
|
||||||
|
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write long-term public key if it doesn't exist
|
// Write long-term public key if it doesn't exist
|
||||||
_, err = m.fs.Stat(ltPubKeyPath)
|
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
||||||
if os.IsNotExist(err) {
|
|
||||||
pubKey := ltIdentity.Recipient().String()
|
pubKey := ltIdentity.Recipient().String()
|
||||||
|
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600); err != nil {
|
||||||
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
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) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
||||||
return nil, errNotImplementedInMock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
|
||||||
m.mnemonic = mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
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
|
// Generate version-specific keypair
|
||||||
versionIdentity, err := age.GenerateX25519Identity()
|
versionIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,10 +71,7 @@ func (m *MockVault) writeVersionFiles(
|
|||||||
|
|
||||||
// Write version public key
|
// Write version public key
|
||||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||||
|
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600); err != nil {
|
||||||
err = afero.WriteFile(
|
|
||||||
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,32 +83,60 @@ func (m *MockVault) writeVersionFiles(
|
|||||||
|
|
||||||
// Write encrypted value
|
// Write encrypted value
|
||||||
valuePath := filepath.Join(versionDir, "value.age")
|
valuePath := filepath.Join(versionDir, "value.age")
|
||||||
|
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600); err != nil {
|
||||||
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt version private key to long-term public key
|
// Encrypt version private key to long-term public key
|
||||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||||
defer versionPrivKeyBuffer.Destroy()
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||||
encryptedPrivKey, err := EncryptToRecipient(
|
|
||||||
versionPrivKeyBuffer, ltIdentity.Recipient())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write encrypted version private key
|
// Write encrypted version private key
|
||||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
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
|
func (m *MockVault) GetName() string {
|
||||||
// public key, and current vault pointer for tests.
|
return m.name
|
||||||
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
}
|
||||||
t.Helper()
|
|
||||||
|
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
|
// Create vault directory structure
|
||||||
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
||||||
@@ -188,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
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to generate long-term identity: %v", err)
|
t.Fatalf("Failed to generate long-term identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write long-term public key
|
// Write long-term public key
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(
|
||||||
fs,
|
fs,
|
||||||
ltPubKeyPath,
|
ltPubKeyPath,
|
||||||
@@ -208,56 +164,10 @@ func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
|||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
||||||
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to set current vault: %v", err)
|
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 // subtests share one vault, order matters
|
|
||||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
|
||||||
// Create an in-memory filesystem for testing
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
|
||||||
defer mnemonic.Destroy()
|
|
||||||
|
|
||||||
// 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
|
// Create vault instance using the mock vault
|
||||||
vault := &MockVault{
|
vault := &MockVault{
|
||||||
@@ -265,7 +175,6 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
fs: fs,
|
fs: fs,
|
||||||
directory: vaultDir,
|
directory: vaultDir,
|
||||||
derivationIndex: 0,
|
derivationIndex: 0,
|
||||||
mnemonic: mnemonic,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
@@ -284,7 +193,30 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that all expected files were created
|
// 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")
|
t.Logf("All expected files created successfully with versioning")
|
||||||
})
|
})
|
||||||
@@ -313,54 +245,87 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error checking if secret exists: %v", err)
|
t.Fatalf("Error checking if secret exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
t.Fatalf("Secret should exist but Exists() returned false")
|
t.Fatalf("Secret should exist but Exists() returned false")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Secret.Exists() works correctly")
|
t.Logf("Secret.Exists() works correctly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
|
// For testing purposes only
|
||||||
// GetValue, given the mnemonic, derives the long-term key at the derivation
|
func isValidSecretName(name string) bool {
|
||||||
// index in the vault's metadata. At index 0 it could not decrypt the secret,
|
if name == "" {
|
||||||
// which was encrypted to the key at index 1.
|
return false
|
||||||
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
}
|
||||||
t.Parallel()
|
// Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
|
||||||
|
for _, char := range name {
|
||||||
fs := afero.NewMemMapFs()
|
if (char < 'a' || char > 'z') && // lowercase letters
|
||||||
vaultDir := "/test-config/vaults.d/test-vault"
|
(char < 'A' || char > 'Z') && // uppercase letters
|
||||||
|
(char < '0' || char > '9') && // numbers
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
char != '-' && // dash
|
||||||
defer mnemonic.Destroy()
|
char != '.' && // dot
|
||||||
|
char != '_' && // underscore
|
||||||
vlt := &MockVault{
|
char != '/' { // slash
|
||||||
name: "test-vault",
|
return false
|
||||||
fs: fs,
|
}
|
||||||
directory: vaultDir,
|
|
||||||
derivationIndex: 1,
|
|
||||||
mnemonic: mnemonic,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
|
return true
|
||||||
require.NoError(t, err)
|
}
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
|
|
||||||
|
func TestSecretNameValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"valid-name", true},
|
||||||
|
{"valid.name", true},
|
||||||
|
{"valid_name", true},
|
||||||
|
{"valid/path/name", true},
|
||||||
|
{"123valid", true},
|
||||||
|
{"", false},
|
||||||
|
{"Valid-Upper-Name", true}, // uppercase allowed
|
||||||
|
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
|
||||||
|
{"MixedCase/Path/Name", true}, // mixed case with path
|
||||||
|
{"invalid name", false}, // space not allowed
|
||||||
|
{"invalid@name", false}, // @ not allowed
|
||||||
|
}
|
||||||
|
|
||||||
err = afero.WriteFile(
|
for _, test := range tests {
|
||||||
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
|
t.Run(test.name, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
result := isValidSecretName(test.name)
|
||||||
|
if result != test.valid {
|
||||||
|
t.Errorf(
|
||||||
|
"isValidSecretName(%q) = %v, want %v",
|
||||||
|
test.name,
|
||||||
|
result,
|
||||||
|
test.valid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
secretName, secretValue := "x", "value"
|
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||||
|
// This test demonstrates the bug where GetValue uses hardcoded index 0
|
||||||
|
// instead of the vault's actual derivation index when using environment mnemonic
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName,
|
// Set up test mnemonic
|
||||||
memguard.NewBufferFromBytes([]byte(secretValue)), false)
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
require.NoError(t, err)
|
t.Setenv(EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
|
// Create temporary directory for vaults
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
_ = fs.RemoveAll(tempDir)
|
||||||
|
}()
|
||||||
|
|
||||||
defer value.Destroy()
|
stateDir := filepath.Join(tempDir, ".secret")
|
||||||
|
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
|
||||||
|
|
||||||
require.Equal(t, secretValue, value.String())
|
// This test is now in the integration test file where it can use real vaults
|
||||||
|
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
||||||
|
t.Log("This test demonstrates the bug in the integration test file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/macse"
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
@@ -31,7 +32,6 @@ const (
|
|||||||
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||||
type SecureEnclaveUnlockerMetadata struct {
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
SEKeyLabel string `json:"seKeyLabel"`
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
SEKeyHash string `json:"seKeyHash"`
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
}
|
}
|
||||||
@@ -43,19 +43,6 @@ type SecureEnclaveUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
|
||||||
func NewSecureEnclaveUnlocker(
|
|
||||||
fs afero.Fs,
|
|
||||||
directory string,
|
|
||||||
metadata UnlockerMetadata,
|
|
||||||
) *SecureEnclaveUnlocker {
|
|
||||||
return &SecureEnclaveUnlocker{
|
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
||||||
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
@@ -71,7 +58,6 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Read ECIES-encrypted long-term private key from disk
|
// Read ECIES-encrypted long-term private key from disk
|
||||||
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||||
|
|
||||||
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -154,9 +140,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
|
|
||||||
if seKeyHash != "" {
|
if seKeyHash != "" {
|
||||||
Debug("Deleting SE key", "hash", seKeyHash)
|
Debug("Deleting SE key", "hash", seKeyHash)
|
||||||
|
if err := macse.DeleteKey(seKeyHash); err != nil {
|
||||||
err = macse.DeleteKey(seKeyHash)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||||
|
|
||||||
return fmt.Errorf("failed to delete SE key: %w", err)
|
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||||
@@ -164,9 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||||
|
if err := s.fs.RemoveAll(s.Directory); err != nil {
|
||||||
err = RemoveDirAtomic(s.fs, s.Directory)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,24 +158,34 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSEKeyInfo reads the SE key label and hash from metadata.
|
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||||
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
|
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
|
||||||
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var seMetadata SecureEnclaveUnlockerMetadata
|
var seMetadata SecureEnclaveUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
|
||||||
err = json.Unmarshal(metadataData, &seMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||||
|
func NewSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
directory string,
|
||||||
|
metadata UnlockerMetadata,
|
||||||
|
) *SecureEnclaveUnlocker {
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||||
func generateSEKeyLabel(vaultName string) (string, error) {
|
func generateSEKeyLabel(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -201,31 +193,25 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
enrollmentDate := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s.%s-%s-%s",
|
"%s.%s-%s-%s",
|
||||||
seKeyLabelPrefix,
|
seKeyLabelPrefix,
|
||||||
vaultName,
|
vaultName,
|
||||||
hostname,
|
hostname,
|
||||||
enrollmentTime,
|
enrollmentDate,
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||||
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||||
// using ECIES. No intermediate age keypair is used.
|
// using ECIES. No intermediate age keypair is used.
|
||||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
|
||||||
// current unlocker, as getLongTermKeyForSE describes.
|
|
||||||
// The SE key is created only once everything that does not need it has
|
|
||||||
// succeeded, and is deleted again if writing the unlocker fails.
|
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
stateDir string,
|
stateDir string,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
err := checkMacOSAvailable()
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +226,17 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get the vault's long-term private key
|
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
|
||||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||||
|
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||||
|
|
||||||
|
// Step 2: Get the vault's long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to get long-term private key: %w",
|
"failed to get long-term private key: %w",
|
||||||
@@ -250,50 +245,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 2: Prepare the unlocker directory's path
|
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
|
||||||
|
|
||||||
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
|
|
||||||
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
|
||||||
|
|
||||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
|
||||||
|
|
||||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
|
||||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
|
||||||
ltPrivKeyData)
|
|
||||||
if err != nil {
|
|
||||||
deleteErr := macse.DeleteKey(seKeyHash)
|
|
||||||
if deleteErr != nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf(
|
|
||||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return unlocker, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
|
||||||
// new unlocker into unlockerDir (steps 4 and 5 of
|
|
||||||
// CreateSecureEnclaveUnlocker).
|
|
||||||
func writeSEUnlocker(
|
|
||||||
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
|
||||||
ltPrivKeyData *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
|
||||||
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
|
||||||
// prepare the metadata
|
|
||||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -302,11 +254,36 @@ func writeSEUnlocker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Create unlocker directory and write files
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to create unlocker directory: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SE-encrypted long-term key
|
||||||
|
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
|
||||||
|
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to write SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
Flags: []string{seUnlockerType, macOSFlag},
|
Flags: []string{seUnlockerType, "macos"},
|
||||||
},
|
},
|
||||||
SEKeyLabel: seKeyLabel,
|
SEKeyLabel: seKeyLabel,
|
||||||
SEKeyHash: seKeyHash,
|
SEKeyHash: seKeyHash,
|
||||||
@@ -317,12 +294,9 @@ func writeSEUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
|
||||||
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
|
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SecureEnclaveUnlocker{
|
return &SecureEnclaveUnlocker{
|
||||||
@@ -332,39 +306,45 @@ func writeSEUnlocker(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
|
// getLongTermKeyForSE retrieves the vault's long-term private key
|
||||||
// SE-encrypted long-term key, then the metadata.
|
// either from the mnemonic env var or by unlocking via the current unlocker.
|
||||||
func writeSEUnlockerFiles(
|
func getLongTermKeyForSE(
|
||||||
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
|
fs afero.Fs,
|
||||||
) error {
|
vault VaultInterface,
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
|
) (*memguard.LockedBuffer, error) {
|
||||||
encryptedLtKey)
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
|
if envMnemonic != "" {
|
||||||
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
"failed to write SE-encrypted long-term key: %w",
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(
|
||||||
|
envMnemonic,
|
||||||
|
metadata.DerivationIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WriteFileAtomic(fs,
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
|
|
||||||
// from mnemonic when it is not nil, else through the current unlocker, which
|
|
||||||
// is given passphrase when it is a passphrase unlocker.
|
|
||||||
func getLongTermKeyForSE(
|
|
||||||
fs afero.Fs,
|
|
||||||
vault VaultInterface,
|
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
if mnemonic != nil {
|
|
||||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||||
@@ -372,10 +352,6 @@ func getLongTermKeyForSE(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
|
||||||
passphraseUnlocker.Passphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -389,7 +365,6 @@ func getLongTermKeyForSE(
|
|||||||
currentUnlocker.GetDirectory(),
|
currentUnlocker.GetDirectory(),
|
||||||
"longterm.age",
|
"longterm.age",
|
||||||
)
|
)
|
||||||
|
|
||||||
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seUnlockerType is the type string for Secure Enclave unlockers.
|
var errSENotSupported = fmt.Errorf(
|
||||||
const seUnlockerType = "secure-enclave"
|
|
||||||
|
|
||||||
var errSENotSupported = errors.New(
|
|
||||||
"secure enclave unlockers are only supported on macOS",
|
"secure enclave unlockers are only supported on macOS",
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||||
type SecureEnclaveUnlockerMetadata struct {
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
SEKeyLabel string `json:"seKeyLabel"`
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
SEKeyHash string `json:"seKeyHash"`
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
}
|
}
|
||||||
@@ -32,21 +28,6 @@ type SecureEnclaveUnlocker struct {
|
|||||||
fs afero.Fs
|
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.
|
// GetIdentity returns an error on non-Darwin platforms.
|
||||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
return nil, errSENotSupported
|
return nil, errSENotSupported
|
||||||
@@ -54,7 +35,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetType returns the unlocker type.
|
// GetType returns the unlocker type.
|
||||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||||
return seUnlockerType
|
return "secure-enclave"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata returns the unlocker metadata.
|
// GetMetadata returns the unlocker metadata.
|
||||||
@@ -69,7 +50,10 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID returns the unlocker ID.
|
// GetID returns the unlocker ID.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
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.
|
// Remove returns an error on non-Darwin platforms.
|
||||||
@@ -77,11 +61,24 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
return errSENotSupported
|
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.
|
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
_ afero.Fs,
|
_ afero.Fs,
|
||||||
_ string,
|
_ string,
|
||||||
_, _ *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
return nil, errSENotSupported
|
return nil, errSENotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
|
// +build !darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,21 +13,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker"
|
dir := "/tmp/test-se-unlocker"
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
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)
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||||
|
|
||||||
// Test GetType returns correct type
|
// 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
|
// Test GetMetadata returns the metadata we passed in
|
||||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||||
@@ -41,11 +39,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,43 +49,37 @@ func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
|||||||
|
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
assert.Nil(t, identity)
|
assert.Nil(t, identity)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
require.ErrorIs(t, err, errSENotSupported)
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
err := unlocker.Remove()
|
err := unlocker.Remove()
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
require.ErrorIs(t, err, errSENotSupported)
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
|
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||||
assert.Nil(t, unlocker)
|
assert.Nil(t, unlocker)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
require.ErrorIs(t, err, errSENotSupported)
|
assert.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,14 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker"
|
dir := "/tmp/test-se-unlocker"
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
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)
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
@@ -37,11 +35,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +48,9 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +63,6 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
label, err := generateSEKeyLabel("test-vault")
|
label, err := generateSEKeyLabel("test-vault")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -80,8 +72,6 @@ func TestGenerateSEKeyLabel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker-missing"
|
dir := "/tmp/test-se-unlocker-missing"
|
||||||
|
|
||||||
@@ -94,12 +84,10 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||||
"seKeyHash": "abc123"
|
"seKeyHash": "abc123"
|
||||||
}`
|
}`
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
|
||||||
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
|
|
||||||
))
|
|
||||||
|
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +96,6 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
// GetIdentity should fail because the encrypted longterm key file is missing
|
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
assert.Nil(t, identity)
|
assert.Nil(t, identity)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
|
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported validateKeychainItemName
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,46 +7,138 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateKeychainItemName(t *testing.T) {
|
func TestValidateKeychainItemName(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
itemName string
|
itemName string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Valid cases
|
// 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 simple name",
|
||||||
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
|
itemName: "my-secret-key",
|
||||||
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
|
wantErr: false,
|
||||||
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
|
},
|
||||||
{name: "valid with dot at start", itemName: ".hidden-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
|
// Invalid cases
|
||||||
{name: "empty item name", itemName: "", wantErr: true},
|
{
|
||||||
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
|
name: "empty item name",
|
||||||
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
|
itemName: "",
|
||||||
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
|
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 spaces",
|
||||||
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
|
itemName: "my secret key",
|
||||||
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
|
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 semicolon",
|
||||||
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
|
itemName: "key;rm -rf /",
|
||||||
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
|
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 pipe",
|
||||||
{name: "item name with question mark", itemName: "key?", wantErr: true},
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := validateKeychainItemName(tt.itemName)
|
err := validateKeychainItemName(tt.itemName)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, 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
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,60 +5,148 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateGPGKeyID(t *testing.T) {
|
func TestValidateGPGKeyID(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
keyID string
|
keyID string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Valid cases
|
// 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)",
|
name: "valid email address",
|
||||||
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
keyID: "test@example.com",
|
||||||
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"valid lowercase hex fingerprint",
|
name: "valid email with dots and hyphens",
|
||||||
"abcdef1234567890abcdef1234567890abcdef12", false,
|
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
|
// 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)",
|
name: "empty key ID",
|
||||||
"test@example.com | cat /etc/passwd", true,
|
keyID: "",
|
||||||
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
|
|
||||||
{
|
{
|
||||||
"key ID with dollar sign (command injection)",
|
name: "key ID with spaces",
|
||||||
"test@example.com$(whoami)", true,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := validateGPGKeyID(tt.keyID)
|
err := validateGPGKeyID(tt.keyID)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|||||||
+116
-238
@@ -2,11 +2,9 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,17 +20,12 @@ const (
|
|||||||
maxVersionsPerDay = 999
|
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
|
// VersionMetadata contains information about a secret version
|
||||||
type VersionMetadata struct {
|
type VersionMetadata struct {
|
||||||
ID string `json:"id"` // ULID
|
ID string `json:"id"` // ULID
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
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
|
// Version represents a version of a secret
|
||||||
@@ -82,8 +75,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// Ensure versions directory exists
|
// Ensure versions directory exists
|
||||||
err := fs.MkdirAll(versionsDir, DirPerms)
|
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +101,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var serial int
|
var serial int
|
||||||
|
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||||
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
|
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
|
||||||
if err != nil {
|
|
||||||
Warn("Skipping malformed version directory name",
|
|
||||||
"name", entry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -126,19 +115,16 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
// Generate new version name
|
// Generate new version name
|
||||||
newSerial := maxSerial + 1
|
newSerial := maxSerial + 1
|
||||||
if newSerial > maxVersionsPerDay {
|
if newSerial > maxVersionsPerDay {
|
||||||
return "", errMaxVersionsPerDay
|
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save saves the version metadata and value. The files are written into a
|
// Save saves the version metadata and value
|
||||||
// temporary directory that is renamed to sv.Directory once all of them are
|
|
||||||
// complete, so the version directory is either whole or absent, even if the
|
|
||||||
// process dies part-way.
|
|
||||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return errNilValueBuffer
|
return fmt.Errorf("value buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugWith("Saving secret version",
|
DebugWith("Saving secret version",
|
||||||
@@ -149,25 +135,15 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
|
|
||||||
fs := sv.vault.GetFilesystem()
|
fs := sv.vault.GetFilesystem()
|
||||||
|
|
||||||
// Create the versions directory the finished version is renamed into
|
// Create version directory
|
||||||
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
|
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||||
if err != nil {
|
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||||
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create versions directory: %w", err)
|
return fmt.Errorf("failed to create version directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpDir, err := TempDirFor(fs, sv.Directory)
|
// Step 1: Generate a new keypair for this version
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
|
||||||
defer func() { _ = fs.RemoveAll(tmpDir) }()
|
|
||||||
|
|
||||||
// Generate a new keypair for this version
|
|
||||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||||
|
|
||||||
versionIdentity, err := age.GenerateX25519Identity()
|
versionIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||||
@@ -175,38 +151,110 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
versionPublicKey := versionIdentity.Recipient().String()
|
||||||
|
// Store private key in memguard buffer immediately
|
||||||
|
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||||
defer versionPrivateKeyBuffer.Destroy()
|
defer versionPrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Generated version keypair",
|
DebugWith("Generated version keypair",
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
slog.String("public_key", versionPublicKey),
|
||||||
)
|
)
|
||||||
|
|
||||||
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
|
// Step 2: Store the version's public key
|
||||||
if err != nil {
|
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||||
return err
|
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, tmpDir, 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 {
|
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, tmpDir, versionIdentity)
|
// Step 4: Store the encrypted value
|
||||||
if err != nil {
|
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||||
return err
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = fs.Rename(tmpDir, sv.Directory)
|
// Step 5: Get vault's long-term public key for encrypting the version's private key
|
||||||
if err != nil {
|
vaultDir, _ := sv.vault.GetDirectory()
|
||||||
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to move version into place: %w", err)
|
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("Successfully saved secret version",
|
Debug("Parsing long-term public key")
|
||||||
"version", sv.Version, "secret_name", sv.SecretName)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -222,11 +270,9 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
|
|
||||||
// Step 1: Read encrypted version private key
|
// Step 1: Read encrypted version private key
|
||||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version private key",
|
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||||
"error", err, "path", encryptedPrivKeyPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -250,11 +296,9 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
|
|
||||||
// Step 4: Read encrypted metadata
|
// Step 4: Read encrypted metadata
|
||||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||||
|
|
||||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version metadata",
|
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
||||||
"error", err, "path", encryptedMetadataPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -270,25 +314,20 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
|
|
||||||
// Step 6: Unmarshal metadata
|
// Step 6: Unmarshal metadata
|
||||||
var metadata VersionMetadata
|
var metadata VersionMetadata
|
||||||
|
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
|
||||||
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sv.Metadata = metadata
|
sv.Metadata = metadata
|
||||||
|
|
||||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the version value
|
// GetValue retrieves and decrypts the version value
|
||||||
func (sv *Version) GetValue(
|
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
|
||||||
ltIdentity *age.X25519Identity,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
DebugWith("Getting version value",
|
DebugWith("Getting version value",
|
||||||
slog.String("secret_name", sv.SecretName),
|
slog.String("secret_name", sv.SecretName),
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
@@ -306,22 +345,16 @@ func (sv *Version) GetValue(
|
|||||||
// Step 1: Read encrypted version private key
|
// Step 1: Read encrypted version private key
|
||||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version private key",
|
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||||
"error", err, "path", encryptedPrivKeyPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
"failed to read encrypted version private key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||||
Debug("Successfully read encrypted version private key",
|
|
||||||
"path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
|
||||||
|
|
||||||
// Step 2: Decrypt version private key using long-term key
|
// Step 2: Decrypt version private key using long-term key
|
||||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||||
|
|
||||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||||
@@ -329,9 +362,7 @@ func (sv *Version) GetValue(
|
|||||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
defer versionPrivKeyBuffer.Destroy()
|
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
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
@@ -344,21 +375,16 @@ func (sv *Version) GetValue(
|
|||||||
// Step 4: Read encrypted value
|
// Step 4: Read encrypted value
|
||||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||||
|
|
||||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted version value",
|
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
||||||
"error", err, "path", encryptedValuePath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||||
}
|
}
|
||||||
|
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
|
||||||
Debug("Successfully read encrypted value",
|
|
||||||
"path", encryptedValuePath, "size", len(encryptedValue))
|
|
||||||
|
|
||||||
// Step 5: Decrypt value using version key
|
// Step 5: Decrypt value using version key
|
||||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||||
|
|
||||||
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||||
@@ -374,142 +400,6 @@ func (sv *Version) GetValue(
|
|||||||
return valueBuffer, nil
|
return valueBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePublicKeyAndValue stores the version's public key and the value
|
|
||||||
// encrypted to it in dir.
|
|
||||||
func (sv *Version) writePublicKeyAndValue(
|
|
||||||
fs afero.Fs,
|
|
||||||
dir string,
|
|
||||||
versionIdentity *age.X25519Identity,
|
|
||||||
value *memguard.LockedBuffer,
|
|
||||||
) error {
|
|
||||||
versionPublicKey := versionIdentity.Recipient().String()
|
|
||||||
pubKeyPath := filepath.Join(dir, "pub.age")
|
|
||||||
Debug("Writing version public key", "path", pubKeyPath)
|
|
||||||
|
|
||||||
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
|
|
||||||
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(dir, "value.age")
|
|
||||||
Debug("Writing encrypted version value", "path", valuePath)
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, valuePath, encryptedValue)
|
|
||||||
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 in dir.
|
|
||||||
func (sv *Version) writeEncryptedPrivateKey(
|
|
||||||
fs afero.Fs,
|
|
||||||
dir string,
|
|
||||||
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(dir, "priv.age")
|
|
||||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
|
||||||
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 in dir.
|
|
||||||
func (sv *Version) writeEncryptedMetadata(
|
|
||||||
fs afero.Fs,
|
|
||||||
dir string,
|
|
||||||
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(dir, "metadata.age")
|
|
||||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
|
||||||
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
|
// ListVersions lists all versions of a secret
|
||||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
@@ -519,7 +409,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -531,7 +420,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var versions []string
|
var versions []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
versions = append(versions, entry.Name())
|
versions = append(versions, entry.Name())
|
||||||
@@ -544,18 +432,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
return versions, nil
|
return versions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionExists reports whether version is one of the versions ListVersions
|
|
||||||
// lists for the secret in secretDir. It only compares names, so a version
|
|
||||||
// the user typed can be checked with it before any path is built from it.
|
|
||||||
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
|
|
||||||
versions, err := ListVersions(fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices.Contains(versions, version), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentVersion returns the version that the "current" file points to
|
// GetCurrentVersion returns the version that the "current" file points to
|
||||||
// The file contains just the version name (e.g., "20231215.001")
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||||
@@ -572,13 +448,15 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentVersion updates the "current" file to point to a specific version
|
// SetCurrentVersion updates the "current" file to point to a specific version
|
||||||
// The file contains just the version name (e.g., "20231215.001"). It is
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
// replaced in one rename, so once written it always exists.
|
|
||||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
err := WriteFileAtomic(fs, currentPath, []byte(version))
|
// Remove existing file if it exists
|
||||||
if err != nil {
|
_ = fs.Remove(currentPath)
|
||||||
|
|
||||||
|
// Write just the version name to the file
|
||||||
|
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil {
|
||||||
return fmt.Errorf("failed to create current version file: %w", err)
|
return fmt.Errorf("failed to create current version file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-101
@@ -32,32 +32,22 @@
|
|||||||
// - Long-term key required for all operations
|
// - Long-term key required for all operations
|
||||||
// - Concurrent reads handled safely
|
// - Concurrent reads handled safely
|
||||||
|
|
||||||
package secret_test
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// MockVault implements VaultInterface for testing
|
||||||
testSecretDir = "/test/secret"
|
|
||||||
testVaultName = "test"
|
|
||||||
testVaultStateDir = "/test"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errNotImplementedInMock = errors.New("not implemented in mock")
|
|
||||||
|
|
||||||
// MockVersionVault implements VaultInterface for testing
|
|
||||||
type MockVersionVault struct {
|
type MockVersionVault struct {
|
||||||
Name string
|
Name string
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
@@ -70,45 +60,31 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||||
return errNotImplementedInMock
|
return fmt.Errorf("not implemented in mock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) GetName() string {
|
func (m *MockVersionVault) GetName() string {
|
||||||
return m.Name
|
return m.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // implements VaultInterface
|
|
||||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||||
return m.fs
|
return m.fs
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // implements VaultInterface
|
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||||
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
return nil, fmt.Errorf("not implemented in mock")
|
||||||
return nil, errNotImplementedInMock
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
return nil, errNotImplementedInMock
|
return nil, fmt.Errorf("not implemented in mock")
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
|
||||||
_ *memguard.LockedBuffer,
|
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
|
||||||
return nil, errNotImplementedInMock
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVersionName(t *testing.T) {
|
func TestGenerateVersionName(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := testSecretDir
|
secretDir := "/test/secret"
|
||||||
|
|
||||||
// Test first version generation
|
// Test first version generation
|
||||||
version1, err := secret.GenerateVersionName(fs, secretDir)
|
version1, err := GenerateVersionName(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||||
|
|
||||||
@@ -118,7 +94,7 @@ func TestGenerateVersionName(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test second version generation on same day
|
// Test second version generation on same day
|
||||||
version2, err := secret.GenerateVersionName(fs, secretDir)
|
version2, err := GenerateVersionName(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||||
|
|
||||||
@@ -128,10 +104,8 @@ func TestGenerateVersionName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := testSecretDir
|
secretDir := "/test/secret"
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// Create 999 versions
|
// Create 999 versions
|
||||||
@@ -143,22 +117,20 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to create one more - should fail
|
// Try to create one more - should fail
|
||||||
_, err := secret.GenerateVersionName(fs, secretDir)
|
_, err := GenerateVersionName(fs, secretDir)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewVersion(t *testing.T) {
|
func TestNewVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: testVaultName,
|
Name: "test",
|
||||||
fs: fs,
|
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, "test/secret", sv.SecretName)
|
||||||
assert.Equal(t, "20231215.001", sv.Version)
|
assert.Equal(t, "20231215.001", sv.Version)
|
||||||
@@ -168,13 +140,11 @@ func TestNewVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionSave(t *testing.T) {
|
func TestSecretVersionSave(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: testVaultName,
|
Name: "test",
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: testVaultStateDir,
|
stateDir: "/test",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create vault directory structure and long-term key
|
// Create vault directory structure and long-term key
|
||||||
@@ -185,21 +155,18 @@ func TestSecretVersionSave(t *testing.T) {
|
|||||||
// Generate and store long-term public key
|
// Generate and store long-term public key
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version
|
// 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")
|
testValue := []byte("test-secret-value")
|
||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(testBuffer)
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -211,13 +178,11 @@ func TestSecretVersionSave(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: testVaultName,
|
Name: "test",
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: testVaultStateDir,
|
stateDir: "/test",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup vault with long-term key
|
// Setup vault with long-term key
|
||||||
@@ -227,16 +192,14 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version with custom metadata
|
// 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()
|
now := time.Now()
|
||||||
epochPlusOne := time.Unix(1, 0)
|
epochPlusOne := time.Unix(1, 0)
|
||||||
sv.Metadata.NotBefore = &epochPlusOne
|
sv.Metadata.NotBefore = &epochPlusOne
|
||||||
@@ -244,12 +207,11 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(testBuffer)
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create new version object and load metadata
|
// Create new version object and load metadata
|
||||||
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
|
sv2 := NewVersion(vault, "test/secret", "20231215.001")
|
||||||
err = sv2.LoadMetadata(ltIdentity)
|
err = sv2.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -261,13 +223,11 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionGetValue(t *testing.T) {
|
func TestSecretVersionGetValue(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: testVaultName,
|
Name: "test",
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: testVaultStateDir,
|
stateDir: "/test",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup vault with long-term key
|
// Setup vault with long-term key
|
||||||
@@ -277,77 +237,64 @@ func TestSecretVersionGetValue(t *testing.T) {
|
|||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version
|
// 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")
|
originalValue := []byte("test-secret-value-12345")
|
||||||
expectedValue := make([]byte, len(originalValue))
|
expectedValue := make([]byte, len(originalValue))
|
||||||
copy(expectedValue, originalValue)
|
copy(expectedValue, originalValue)
|
||||||
|
|
||||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||||
defer originalBuffer.Destroy()
|
defer originalBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(originalBuffer)
|
err = sv.Save(originalBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Retrieve the value
|
// Retrieve the value
|
||||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer retrievedBuffer.Destroy()
|
defer retrievedBuffer.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersions(t *testing.T) {
|
func TestListVersions(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := testSecretDir
|
secretDir := "/test/secret"
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// No versions directory
|
// No versions directory
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, versions)
|
assert.Empty(t, versions)
|
||||||
|
|
||||||
// Create some versions
|
// Create some versions
|
||||||
testVersions := []string{
|
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||||
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
|
|
||||||
}
|
|
||||||
for _, v := range testVersions {
|
for _, v := range testVersions {
|
||||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a file (not directory) that should be ignored
|
// Create a file (not directory) that should be ignored
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||||
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// List versions
|
// List versions
|
||||||
versions, err = secret.ListVersions(fs, secretDir)
|
versions, err = ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should be sorted in reverse chronological order
|
// Should be sorted in reverse chronological order
|
||||||
expected := []string{
|
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
|
||||||
"20231216.001", "20231215.002", "20231215.001", "20231214.001",
|
|
||||||
}
|
|
||||||
assert.Equal(t, expected, versions)
|
assert.Equal(t, expected, versions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentVersion(t *testing.T) {
|
func TestGetCurrentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := testSecretDir
|
secretDir := "/test/secret"
|
||||||
|
|
||||||
// The current file contains just the version name
|
// The current file contains just the version name
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
@@ -357,43 +304,39 @@ func TestGetCurrentVersion(t *testing.T) {
|
|||||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
version, err := GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231216.001", version)
|
assert.Equal(t, "20231216.001", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetCurrentVersion(t *testing.T) {
|
func TestSetCurrentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := testSecretDir
|
secretDir := "/test/secret"
|
||||||
|
|
||||||
err := fs.MkdirAll(secretDir, 0o755)
|
err := fs.MkdirAll(secretDir, 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current version
|
// Set current version
|
||||||
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify it was set
|
// Verify it was set
|
||||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
version, err := GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231216.002", version)
|
assert.Equal(t, "20231216.002", version)
|
||||||
|
|
||||||
// Update to different version
|
// Update to different version
|
||||||
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
version, err = secret.GetCurrentVersion(fs, secretDir)
|
version, err = GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231217.001", version)
|
assert.Equal(t, "20231217.001", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Test that all timestamp fields behave consistently as pointers
|
// Test that all timestamp fields behave consistently as pointers
|
||||||
vm := secret.VersionMetadata{
|
vm := VersionMetadata{
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +368,5 @@ func TestVersionMetadataTimestamps(t *testing.T) {
|
|||||||
// Helper function
|
// Helper function
|
||||||
func fileExists(fs afero.Fs, path string) bool {
|
func fileExists(fs afero.Fs, path string) bool {
|
||||||
exists, _ := afero.Exists(fs, path)
|
exists, _ := afero.Exists(fs, path)
|
||||||
|
|
||||||
return exists
|
return exists
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +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 breaks the naming
|
|
||||||
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
|
|
||||||
// empty, "." or "..". Composed by ValidateVaultName as
|
|
||||||
// "invalid vault name '<name>': <the rule>".
|
|
||||||
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")
|
|
||||||
|
|
||||||
// ErrVaultExists indicates that a vault to be created already exists.
|
|
||||||
// Composed as "vault <name> already exists".
|
|
||||||
ErrVaultExists = errors.New("already exists")
|
|
||||||
|
|
||||||
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
|
||||||
ErrNilValueBuffer = errors.New("value buffer is nil")
|
|
||||||
|
|
||||||
// ErrInvalidSecretName indicates a secret name that breaks the naming
|
|
||||||
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
|
|
||||||
// no leading '.' or '/', no trailing '/', no '//', no '..' path segment.
|
|
||||||
// Composed by ValidateSecretName as
|
|
||||||
// "invalid secret name '<name>': <the rule>".
|
|
||||||
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")
|
|
||||||
|
|
||||||
// ErrNoLockForFilesystem indicates LockStateDir was given a filesystem
|
|
||||||
// it cannot lock. Composed as "cannot lock the state directory on
|
|
||||||
// filesystem <type>".
|
|
||||||
ErrNoLockForFilesystem = errors.New(
|
|
||||||
"cannot lock the state directory on filesystem")
|
|
||||||
)
|
|
||||||
+117
-165
@@ -1,105 +1,40 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create a temporary directory for our tests
|
// Create a temporary directory for our tests
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
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, "test-passphrase")
|
||||||
|
|
||||||
// Test currentvault file handling (plain file with relative path)
|
// Test currentvault file handling (plain file with relative path)
|
||||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testCurrentVaultFileHandling(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test secret operations with deeply nested paths
|
|
||||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
testDeepPathSecrets(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test key caching in GetOrDeriveLongTermKey
|
|
||||||
t.Run("KeyCaching", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
testKeyCaching(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test vault name validation
|
|
||||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
testVaultNameValidation(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test multiple vaults and switching between them
|
|
||||||
t.Run("MultipleVaults", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
testMultipleVaults(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test adding a secret in one vault and verifying it's not visible in
|
|
||||||
// another
|
|
||||||
t.Run("VaultIsolation", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
testVaultIsolation(t, fs, tempDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
stateDir := filepath.Join(tempDir, "currentvault-test")
|
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault
|
// Create a test vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -112,15 +47,14 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Verify the currentvault file exists and contains just the vault name
|
// Verify the currentvault file exists and contains just the vault name
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
currentVaultContents, err := os.ReadFile(currentVaultPath)
|
||||||
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read currentvault file: %v", err)
|
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(currentVaultContents) != testVaultName {
|
expectedVaultName := "test-vault"
|
||||||
t.Errorf("Expected currentvault to contain %q, got %q",
|
if string(currentVaultContents) != expectedVaultName {
|
||||||
testVaultName, string(currentVaultContents))
|
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that ResolveVaultSymlink correctly resolves the path
|
// Test that ResolveVaultSymlink correctly resolves the path
|
||||||
@@ -132,22 +66,17 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if resolvedPath != vaultDir {
|
if resolvedPath != vaultDir {
|
||||||
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test secret operations with deeply nested paths
|
||||||
|
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||||
// the mnemonic
|
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -157,15 +86,13 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive long-term key from mnemonic using the vault's derivation index
|
// Derive long-term key from mnemonic using the vault's derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
||||||
vaultMetadata.DerivationIndex)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,7 +120,15 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to list secrets: %v", err)
|
t.Fatalf("Failed to list secrets: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(secrets, deepPath) {
|
found := false
|
||||||
|
for _, s := range secrets {
|
||||||
|
if s == deepPath {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
t.Errorf("Deep path secret not found in listed secrets")
|
t.Errorf("Deep path secret not found in listed secrets")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,28 +137,22 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||||
}
|
}
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||||
expectedValue, retrievedValue.Bytes())
|
string(expectedValue), string(retrievedValue))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test key caching in GetOrDeriveLongTermKey
|
||||||
|
t.Run("KeyCaching", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||||
// the mnemonic
|
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,16 +162,13 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive long-term key from mnemonic for verification using the
|
// Derive long-term key from mnemonic for verification using the vault's derivation index
|
||||||
// vault's derivation index
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
|
||||||
vaultMetadata.DerivationIndex)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -276,16 +202,13 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Verify the public key matches what we expect
|
// Verify the public key matches what we expect
|
||||||
expectedPubKey := ltIdentity.Recipient().String()
|
expectedPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
actualPubKey := firstKey.Recipient().String()
|
actualPubKey := firstKey.Recipient().String()
|
||||||
if actualPubKey != expectedPubKey {
|
if actualPubKey != expectedPubKey {
|
||||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||||
expectedPubKey, actualPubKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now clear the key and verify it's locked again
|
// Now clear the key and verify it's locked again
|
||||||
vlt.ClearLongTermKey()
|
vlt.ClearLongTermKey()
|
||||||
|
|
||||||
if !vlt.Locked() {
|
if !vlt.Locked() {
|
||||||
t.Errorf("Vault should be locked after clearing key")
|
t.Errorf("Vault should be locked after clearing key")
|
||||||
}
|
}
|
||||||
@@ -299,18 +222,14 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Verify the public key still matches
|
// Verify the public key still matches
|
||||||
actualPubKey = thirdKey.Recipient().String()
|
actualPubKey = thirdKey.Recipient().String()
|
||||||
if actualPubKey != expectedPubKey {
|
if actualPubKey != expectedPubKey {
|
||||||
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
|
t.Errorf("Re-derived public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||||
expectedPubKey, actualPubKey)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test vault name validation
|
||||||
|
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +243,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range validNames {
|
for _, name := range validNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -340,28 +259,24 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Expected error creating vault with invalid name %q, "+
|
t.Errorf("Expected error creating vault with invalid name %q, but got none", name)
|
||||||
"but got none", name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test multiple vaults and switching between them
|
||||||
|
t.Run("MultipleVaults", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create three vaults
|
// Create three vaults
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -380,65 +295,85 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Test switching between vaults
|
// Test switching between vaults
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
// Select the vault
|
// Select the vault
|
||||||
err := vault.SelectVault(fs, stateDir, name)
|
if err := vault.SelectVault(fs, stateDir, name); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to select vault %s: %v", name, err)
|
t.Fatalf("Failed to select vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current vault and verify it's the one we selected
|
// Get current vault and verify it's the one we selected
|
||||||
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get current vault after selecting %s: %v",
|
t.Fatalf("Failed to get current vault after selecting %s: %v", name, err)
|
||||||
name, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentVault.GetName() != name {
|
if currentVault.GetName() != name {
|
||||||
t.Errorf("Expected current vault to be %s, got %s",
|
t.Errorf("Expected current vault to be %s, got %s", name, currentVault.GetName())
|
||||||
name, currentVault.GetName())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Test adding a secret in one vault and verifying it's not visible in another
|
||||||
|
t.Run("VaultIsolation", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
err := os.MkdirAll(stateDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create two vaults - CreateVault writes the public key derived from
|
// Create two vaults - CreateVault now handles public key when mnemonic is in env
|
||||||
// the mnemonic
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive long-term keys from mnemonic
|
// Derive long-term key from mnemonic
|
||||||
// Note: Both vaults will have different derivation indexes due to
|
// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex
|
||||||
// GetNextDerivationIndex
|
|
||||||
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
|
// Load vault1 metadata to get its derivation index
|
||||||
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
|
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
|
// Unlock the vaults with their respective keys
|
||||||
vault1.Unlock(ltIdentity1)
|
vault1.Unlock(ltIdentity1)
|
||||||
vault2.Unlock(ltIdentity2)
|
vault2.Unlock(ltIdentity2)
|
||||||
|
|
||||||
// Add a secret to vault1
|
// Add a secret to vault1
|
||||||
|
secretName := "test-secret"
|
||||||
secretValue := []byte("secret in vault1")
|
secretValue := []byte("secret in vault1")
|
||||||
|
|
||||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
defer secretBuffer.Destroy()
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
err = vault1.AddSecret(testSecretName, secretBuffer, false)
|
if err := vault1.AddSecret(secretName, secretBuffer, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +383,15 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(vault1Secrets, testSecretName) {
|
found := false
|
||||||
|
for _, s := range vault1Secrets {
|
||||||
|
if s == secretName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
t.Errorf("Secret not found in vault1")
|
t.Errorf("Secret not found in vault1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +401,16 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(vault2Secrets, testSecretName) {
|
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")
|
t.Errorf("Secret from vault1 should not be visible in vault2")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,17 +19,14 @@
|
|||||||
// - Consistent test mnemonic for reproducible keys
|
// - Consistent test mnemonic for reproducible keys
|
||||||
// - Proper cleanup and isolation between tests
|
// - Proper cleanup and isolation between tests
|
||||||
|
|
||||||
//nolint:testpackage // uses white-box test helpers shared with this package
|
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -38,30 +35,38 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnexpectedValue is returned by concurrent readers when a secret value
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
// does not match the expected contents.
|
func addTestSecret(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||||
var errUnexpectedValue = errors.New("unexpected value")
|
t.Helper()
|
||||||
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
|
defer buffer.Destroy()
|
||||||
|
err := vault.AddSecret(name, buffer, force)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||||
//
|
|
||||||
//nolint:paralleltest // the subtests are steps that build on each other
|
|
||||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Set mnemonic for testing
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
t.Setenv(secret.EnvMnemonic,
|
||||||
|
"abandon abandon abandon abandon abandon abandon "+
|
||||||
|
"abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vault, err := CreateVault(fs, stateDir, "test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// 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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Unlock the vault
|
// Unlock the vault
|
||||||
@@ -71,55 +76,12 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
// Step 1: Create initial version
|
// Step 1: Create initial version
|
||||||
t.Run("create_initial_version", func(t *testing.T) {
|
t.Run("create_initial_version", func(t *testing.T) {
|
||||||
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
addTestSecret(t, vault, secretName, []byte("version-1-data"), false)
|
||||||
})
|
|
||||||
|
|
||||||
// Step 2: Create second version
|
|
||||||
t.Run("create_second_version", func(t *testing.T) {
|
|
||||||
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Create third version
|
|
||||||
t.Run("create_third_version", func(t *testing.T) {
|
|
||||||
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 4: Retrieve specific versions
|
|
||||||
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
|
||||||
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 5: Promote old version to current
|
|
||||||
t.Run("promote_old_version", func(t *testing.T) {
|
|
||||||
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 6: Test version limits
|
|
||||||
t.Run("version_serial_limits", func(t *testing.T) {
|
|
||||||
testVersionSerialLimits(t, fs, vault, vaultDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 7: Test error cases
|
|
||||||
t.Run("error_cases", func(t *testing.T) {
|
|
||||||
testVersionErrorCases(t, vault, secretName)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Verify secret can be retrieved
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify version directory structure
|
// Verify version directory structure
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
@@ -139,37 +101,28 @@ func testCreateInitialVersion(
|
|||||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||||
assert.NotNil(t, version.Metadata.NotBefore)
|
assert.NotNil(t, version.Metadata.NotBefore)
|
||||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||||
// NotAfter should be nil for current version
|
assert.Nil(t, version.Metadata.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()
|
|
||||||
|
|
||||||
|
// Step 2: Create second version
|
||||||
|
var firstVersionName string
|
||||||
|
t.Run("create_second_version", func(t *testing.T) {
|
||||||
// Small delay to ensure different timestamps
|
// Small delay to ensure different timestamps
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
// Get first version name before creating second
|
// Get first version name before creating second
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
firstVersionName = versions[0]
|
||||||
firstVersionName := versions[0]
|
|
||||||
|
|
||||||
// Create second version
|
// Create second version
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
|
addTestSecret(t, vault, secretName, []byte("version-2-data"), true)
|
||||||
|
|
||||||
// Verify new value is current
|
// Verify new value is current
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify we now have two versions
|
// Verify we now have two versions
|
||||||
versions, err = secret.ListVersions(fs, secretDir)
|
versions, err = secret.ListVersions(fs, secretDir)
|
||||||
@@ -190,18 +143,14 @@ func testCreateSecondVersion(
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||||
|
|
||||||
// NotBefore of second should equal NotAfter of first
|
// NotBefore of second should equal NotAfter of first
|
||||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
|
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix())
|
||||||
secondVersion.Metadata.NotBefore.Unix())
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func testCreateThirdVersion(
|
|
||||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Step 3: Create third version
|
||||||
|
t.Run("create_third_version", func(t *testing.T) {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
|
addTestSecret(t, vault, secretName, []byte("version-3-data"), true)
|
||||||
|
|
||||||
// Verify we now have three versions
|
// Verify we now have three versions
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
@@ -212,17 +161,11 @@ func testCreateThirdVersion(
|
|||||||
// Current should be version-3
|
// Current should be version-3
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value)
|
||||||
|
})
|
||||||
|
|
||||||
defer value.Destroy()
|
// Step 4: Retrieve specific versions
|
||||||
|
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
||||||
assert.Equal(t, []byte("version-3-data"), value.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRetrieveSpecificVersions(
|
|
||||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -231,37 +174,24 @@ func testRetrieveSpecificVersions(
|
|||||||
// Get each version by its name
|
// Get each version by its name
|
||||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value1)
|
||||||
defer value1.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
|
|
||||||
|
|
||||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value2)
|
||||||
defer value2.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
|
|
||||||
|
|
||||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value3)
|
||||||
|
|
||||||
defer value3.Destroy()
|
// Empty version should return current
|
||||||
|
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||||
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
})
|
||||||
// current one
|
|
||||||
_, err = vault.GetSecretVersion(secretName, "")
|
|
||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPromoteOldVersion(
|
|
||||||
t *testing.T, fs afero.Fs, vault *Vault,
|
|
||||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Step 5: Promote old version to current
|
||||||
|
t.Run("promote_old_version", func(t *testing.T) {
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -274,31 +204,24 @@ func testPromoteOldVersion(
|
|||||||
// Verify current now returns the old version's value
|
// Verify current now returns the old version's value
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify the version metadata hasn't changed
|
// Verify the version metadata hasn't changed
|
||||||
// (promoting shouldn't modify timestamps)
|
// (promoting shouldn't modify timestamps)
|
||||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||||
err = version.LoadMetadata(ltIdentity)
|
err = version.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// should still have its old notAfter
|
assert.NotNil(t, version.Metadata.NotAfter) // 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()
|
|
||||||
|
|
||||||
|
// Step 6: Test version limits
|
||||||
|
t.Run("version_serial_limits", func(t *testing.T) {
|
||||||
// Create a new secret for this test
|
// Create a new secret for this test
|
||||||
limitSecretName := "limit/test"
|
limitSecretName := "limit/test"
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||||
|
|
||||||
// Create 998 versions (we already have one from the first AddSecret)
|
// Create 998 versions (we already have one from the first AddSecret)
|
||||||
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
|
addTestSecret(t, vault, limitSecretName, []byte("initial"), false)
|
||||||
|
|
||||||
// Get today's date for consistent version names
|
// Get today's date for consistent version names
|
||||||
today := time.Now().Format("20060102")
|
today := time.Now().Format("20060102")
|
||||||
@@ -314,7 +237,7 @@ func testVersionSerialLimits(
|
|||||||
// Should be able to create one more (999)
|
// Should be able to create one more (999)
|
||||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, today+".999", versionName)
|
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
|
||||||
|
|
||||||
// Create the 999th version directory
|
// Create the 999th version directory
|
||||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||||
@@ -322,66 +245,56 @@ func testVersionSerialLimits(
|
|||||||
|
|
||||||
// Should fail to create 1000th version
|
// Should fail to create 1000th version
|
||||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
}
|
})
|
||||||
|
|
||||||
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Step 7: Test error cases
|
||||||
|
t.Run("error_cases", func(t *testing.T) {
|
||||||
// Try to get non-existent version
|
// Try to get non-existent version
|
||||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
|
||||||
// Try to get version of non-existent secret
|
// Try to get version of non-existent secret
|
||||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// Try to add secret without force when it exists
|
// Try to add secret without force when it exists
|
||||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||||
defer failBuffer.Destroy()
|
defer failBuffer.Destroy()
|
||||||
|
|
||||||
err = vault.AddSecret(secretName, failBuffer, false)
|
err = vault.AddSecret(secretName, failBuffer, false)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionConcurrency tests concurrent version operations
|
// TestVersionConcurrency tests concurrent version operations
|
||||||
func TestVersionConcurrency(t *testing.T) {
|
func TestVersionConcurrency(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
secretName := "concurrent/test"
|
secretName := "concurrent/test"
|
||||||
|
|
||||||
// Create initial version
|
// Create initial version
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
|
addTestSecret(t, vault, secretName, []byte("initial"), false)
|
||||||
|
|
||||||
// Test concurrent reads
|
// Test concurrent reads
|
||||||
t.Run("concurrent_reads", func(t *testing.T) {
|
t.Run("concurrent_reads", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
errCh := make(chan error, 10)
|
errors := make(chan error, 10)
|
||||||
|
|
||||||
for range 10 {
|
for range 10 {
|
||||||
go func() {
|
go func() {
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errors <- err
|
||||||
} else {
|
} else if string(value) != "initial" {
|
||||||
if value.String() != "initial" {
|
errors <- fmt.Errorf("unexpected value: %s", value)
|
||||||
errCh <- fmt.Errorf("%w: %s",
|
|
||||||
errUnexpectedValue, value.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -393,7 +306,7 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errors:
|
||||||
t.Fatalf("concurrent read failed: %v", err)
|
t.Fatalf("concurrent read failed: %v", err)
|
||||||
default:
|
default:
|
||||||
// No errors
|
// No errors
|
||||||
@@ -403,13 +316,11 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
|
|
||||||
// TestVersionCompatibility tests that old secrets without versions still work
|
// TestVersionCompatibility tests that old secrets without versions still work
|
||||||
func TestVersionCompatibility(t *testing.T) {
|
func TestVersionCompatibility(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -422,12 +333,9 @@ func TestVersionCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
// Create old-style encrypted value directly in secret directory
|
// Create old-style encrypted value directly in secret directory
|
||||||
testValue := []byte("legacy-value")
|
testValue := []byte("legacy-value")
|
||||||
|
|
||||||
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
defer testValueBuffer.Destroy()
|
defer testValueBuffer.Destroy()
|
||||||
|
|
||||||
ltRecipient := ltIdentity.Recipient()
|
ltRecipient := ltIdentity.Recipient()
|
||||||
|
|
||||||
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -437,7 +345,7 @@ func TestVersionCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
// Should fail to get with version-aware methods
|
// Should fail to get with version-aware methods
|
||||||
_, err = vault.GetSecret(secretName)
|
_, err = vault.GetSecret(secretName)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// List versions should return empty
|
// List versions should return empty
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package vault
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
|
||||||
const lockFileName = "lock"
|
|
||||||
|
|
||||||
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
|
||||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // must outlive the call that takes it
|
|
||||||
var memFsLock sync.Mutex
|
|
||||||
|
|
||||||
// LockStateDir takes the lock that a command changing anything under
|
|
||||||
// stateDir holds until it returns, and returns the function that releases
|
|
||||||
// it. While one command holds it, the next one waits here. Reads take no
|
|
||||||
// lock: each file or directory a command changes is replaced in a single
|
|
||||||
// rename, so a reader finds it as it was before or after, never half-made.
|
|
||||||
//
|
|
||||||
// On the real filesystem the lock is flock(2) on the file "lock" in
|
|
||||||
// stateDir, which the kernel releases when the process dies, so a killed
|
|
||||||
// command never leaves the tool locked. The in-memory filesystem the tests
|
|
||||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
|
||||||
// Any other filesystem is refused rather than left unlocked.
|
|
||||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
|
||||||
switch fs.(type) {
|
|
||||||
case *afero.OsFs:
|
|
||||||
return flockStateDir(stateDir)
|
|
||||||
case *afero.MemMapFs:
|
|
||||||
memFsLock.Lock()
|
|
||||||
|
|
||||||
return memFsLock.Unlock, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
|
||||||
// directory and the file if needed. Go opens files close-on-exec, so
|
|
||||||
// programs the command runs, such as gpg, do not inherit the lock.
|
|
||||||
func flockStateDir(stateDir string) (func(), error) {
|
|
||||||
err := os.MkdirAll(stateDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create state directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lockPath := filepath.Join(stateDir, lockFileName)
|
|
||||||
|
|
||||||
//nolint:gosec // G304: the path is the lock file in the state directory
|
|
||||||
file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open lock file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to lock %s: %w", lockPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closing the file releases the lock.
|
|
||||||
return func() { _ = file.Close() }, nil
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package vault_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// lockWait is how long a test waits for the lock before deciding it
|
|
||||||
// will never come free.
|
|
||||||
lockWait = 10 * time.Second
|
|
||||||
|
|
||||||
// heldWait is how long a test watches a second holder fail to take a
|
|
||||||
// lock that is held. Broken exclusion lets it in at once.
|
|
||||||
heldWait = 100 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockFilesystem is a filesystem LockStateDir can lock, with a state
|
|
||||||
// directory on it.
|
|
||||||
type lockFilesystem struct {
|
|
||||||
name string
|
|
||||||
fs afero.Fs
|
|
||||||
stateDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockFilesystems returns the real filesystem, locked with flock, and the
|
|
||||||
// in-memory one, locked with a mutex.
|
|
||||||
func lockFilesystems(t *testing.T) []lockFilesystem {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return []lockFilesystem{
|
|
||||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockInBackground starts taking the lock and returns a channel that
|
|
||||||
// delivers the function releasing it once it has been taken.
|
|
||||||
func lockInBackground(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string,
|
|
||||||
) <-chan func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
taken := make(chan func(), 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
release, err := vault.LockStateDir(fs, stateDir)
|
|
||||||
if assert.NoError(t, err) {
|
|
||||||
taken <- release
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return taken
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirExcludes checks that while the lock is held a second
|
|
||||||
// holder, with its own open lock file on the real filesystem, waits, and
|
|
||||||
// that it gets the lock once the first releases it.
|
|
||||||
func TestLockStateDirExcludes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, lfs := range lockFilesystems(t) {
|
|
||||||
t.Run(lfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
taken := lockInBackground(t, lfs.fs, lfs.stateDir)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case second := <-taken:
|
|
||||||
second()
|
|
||||||
release()
|
|
||||||
t.Fatal("a second holder took the lock while it was held")
|
|
||||||
case <-time.After(heldWait):
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case second := <-taken:
|
|
||||||
second()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the second holder never got the lock")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirFreeAfterPanic checks that a holder that panics, and
|
|
||||||
// releases the lock with defer as every command does, leaves it free.
|
|
||||||
func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, lfs := range lockFilesystems(t) {
|
|
||||||
t.Run(lfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Panics(t, func() {
|
|
||||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
panic("the command failed")
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-lockInBackground(t, lfs.fs, lfs.stateDir):
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the lock was still held after its holder panicked")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
|
||||||
// lock implementation is refused instead of being used unlocked.
|
|
||||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.ErrorIs(t, err, vault.ErrNoLockForFilesystem)
|
|
||||||
assert.Nil(t, release)
|
|
||||||
}
|
|
||||||
+46
-113
@@ -3,6 +3,7 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,49 +11,27 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register the GetCurrentVault function with the secret package
|
// Register the GetCurrentVault function with the secret package
|
||||||
//
|
|
||||||
//nolint:gochecknoinits // registers the vault accessor with the secret package
|
|
||||||
func init() {
|
func init() {
|
||||||
secret.RegisterGetCurrentVaultFunc(
|
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||||
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
|
||||||
return GetCurrentVault(fs, stateDir)
|
return GetCurrentVault(fs, stateDir)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidVaultName reports whether name is a valid vault name: only
|
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
||||||
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
|
// Note: We don't allow slashes in vault names unlike secret names
|
||||||
// "..". With no path separator allowed, a vault is always one directory
|
|
||||||
// directly under vaults.d.
|
|
||||||
func isValidVaultName(name string) bool {
|
func isValidVaultName(name string) bool {
|
||||||
if name == "" || name == "." || name == ".." {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||||
|
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
|
|
||||||
// is not a valid vault name. Call it on the name exactly as the user gave it,
|
|
||||||
// before building any path from it.
|
|
||||||
func ValidateVaultName(name string) error {
|
|
||||||
if !isValidVaultName(name) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
|
|
||||||
"are allowed, and a name must not be empty, '.' or '..'",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// The file contains just the vault name (e.g., "default")
|
// The file contains just the vault name (e.g., "default")
|
||||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
@@ -86,11 +65,9 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||||
|
|
||||||
_, err := fs.Stat(currentVaultPath)
|
_, err := fs.Stat(currentVaultPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current vault symlink",
|
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
||||||
"error", err, "path", currentVaultPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -99,7 +76,6 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
|
|
||||||
// Resolve the symlink to get the actual vault directory
|
// Resolve the symlink to get the actual vault directory
|
||||||
secret.Debug("Resolving vault symlink")
|
secret.Debug("Resolving vault symlink")
|
||||||
|
|
||||||
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -112,8 +88,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
vaultName := filepath.Base(targetPath)
|
vaultName := filepath.Base(targetPath)
|
||||||
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
||||||
|
|
||||||
secret.Debug("Current vault resolved",
|
secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath)
|
||||||
"vault_name", vaultName, "target_path", targetPath)
|
|
||||||
|
|
||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
return NewVault(fs, stateDir, vaultName), nil
|
return NewVault(fs, stateDir, vaultName), nil
|
||||||
@@ -128,7 +103,6 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -141,7 +115,6 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
|
|
||||||
// Extract vault names
|
// Extract vault names
|
||||||
var vaults []string
|
var vaults []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
vaults = append(vaults, entry.Name())
|
vaults = append(vaults, entry.Name())
|
||||||
@@ -151,25 +124,22 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
return vaults, nil
|
return vaults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
// 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) (
|
||||||
func processMnemonicForVault(
|
derivationIndex uint32, publicKeyHash string, familyHash string, err error) {
|
||||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
// Check if mnemonic is available in environment
|
||||||
mnemonicBuffer *memguard.LockedBuffer,
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
) (uint32, string, string, error) {
|
|
||||||
if mnemonicBuffer == nil {
|
if mnemonic == "" {
|
||||||
secret.Debug("No mnemonic given, vault created without long-term key",
|
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", vaultName)
|
||||||
"vault", vaultName)
|
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return 0, "", "", nil
|
return 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := mnemonicBuffer.String()
|
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", vaultName)
|
||||||
|
|
||||||
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
|
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
}
|
}
|
||||||
@@ -182,18 +152,14 @@ func processMnemonicForVault(
|
|||||||
|
|
||||||
// Write the public key
|
// Write the public key
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
|
|
||||||
// Compute verification hash from actual derivation index
|
// 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)
|
// 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
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
@@ -201,73 +167,46 @@ func processMnemonicForVault(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
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
|
return derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault and selects it as the current vault. When
|
// CreateVault creates a new vault
|
||||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
|
||||||
// long-term key until one is imported. It refuses a vault that already
|
|
||||||
// exists before writing anything: creating it again would replace its keys,
|
|
||||||
// and its secrets could no longer be decrypted. The commands that call it
|
|
||||||
// hold the state directory lock, so no other command can create the vault
|
|
||||||
// between the check and the writes.
|
|
||||||
func CreateVault(
|
|
||||||
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*Vault, error) {
|
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
err := ValidateVaultName(name)
|
// Validate vault name
|
||||||
if err != nil {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, err
|
return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create vault directory structure
|
// Create vault directory structure
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Create main vault directory
|
// Create main vault directory
|
||||||
err = fs.MkdirAll(vaultDir, secret.DirPerms)
|
if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secrets directory
|
// Create secrets directory
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
if err := fs.MkdirAll(secretsDir, secret.DirPerms); err != nil {
|
||||||
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlockers directory
|
// Create unlockers directory
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil {
|
||||||
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process mnemonic if available
|
// Process mnemonic if available
|
||||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name)
|
||||||
fs, stateDir, vaultDir, name, mnemonic)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -279,62 +218,56 @@ func CreateVault(
|
|||||||
PublicKeyHash: publicKeyHash,
|
PublicKeyHash: publicKeyHash,
|
||||||
MnemonicFamilyHash: familyHash,
|
MnemonicFamilyHash: familyHash,
|
||||||
}
|
}
|
||||||
|
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||||
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the newly created vault as current
|
// Select the newly created vault as current
|
||||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||||
|
if err := SelectVault(fs, stateDir, name); err != nil {
|
||||||
err = SelectVault(fs, stateDir, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to select vault: %w", err)
|
return nil, fmt.Errorf("failed to select vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
secret.Debug("Successfully created vault", "name", name)
|
secret.Debug("Successfully created vault", "name", name)
|
||||||
|
|
||||||
vlt := NewVault(fs, stateDir, name)
|
return NewVault(fs, stateDir, name), nil
|
||||||
vlt.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
return vlt, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectVault selects the given vault as the current vault
|
// SelectVault selects the given vault as the current vault
|
||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
err := ValidateVaultName(name)
|
// Validate vault name
|
||||||
if err != nil {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return err
|
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
// Check if vault exists
|
// Check if vault exists
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, vaultDir)
|
exists, err := afero.DirExists(fs, vaultDir)
|
||||||
if err != nil {
|
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 {
|
if !exists {
|
||||||
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
return fmt.Errorf("vault %s does not exist", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or replace the currentvault file with just the vault name. It
|
// Create or update the currentvault file with just the vault name
|
||||||
// is replaced in one rename, so it never goes missing.
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
secret.Debug("Writing currentvault file", "vault_name", name)
|
// Remove existing file if it exists
|
||||||
|
if _, err := fs.Stat(currentVaultPath); err == nil {
|
||||||
|
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
||||||
|
_ = fs.Remove(currentVaultPath)
|
||||||
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
|
// Write just the vault name to the file
|
||||||
if err != nil {
|
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||||
|
if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil {
|
||||||
return fmt.Errorf("failed to select vault: %w", err)
|
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
|
// 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
|
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||||
func GetNextDerivationIndex(
|
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint32, error) {
|
||||||
fs afero.Fs, stateDir string, mnemonic string,
|
|
||||||
) (uint32, error) {
|
|
||||||
// First, derive the public key for index 0 to get our identifier
|
// First, derive the public key for index 0 to get our identifier
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||||
@@ -52,7 +49,6 @@ func GetNextDerivationIndex(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// No vaults yet, start with index 0
|
// No vaults yet, start with index 0
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -74,7 +70,6 @@ func GetNextDerivationIndex(
|
|||||||
|
|
||||||
// Try to read vault metadata
|
// Try to read vault metadata
|
||||||
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip vaults without metadata
|
// Skip vaults without metadata
|
||||||
@@ -82,9 +77,7 @@ func GetNextDerivationIndex(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
// Skip vaults with invalid metadata
|
// Skip vaults with invalid metadata
|
||||||
continue
|
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)
|
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write vault metadata: %w", err)
|
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
|
var metadata Metadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-171
@@ -1,42 +1,25 @@
|
|||||||
package vault_test
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
|
||||||
func TestVaultMetadata(t *testing.T) {
|
func TestVaultMetadata(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
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) {
|
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
||||||
testComputeDoubleSHA256(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
|
||||||
testGetNextDerivationIndex(t, fs)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MetadataPersistence", func(t *testing.T) {
|
|
||||||
testMetadataPersistence(t, fs)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
|
||||||
testDifferentKeysForDifferentIndices(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func testComputeDoubleSHA256(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
data := []byte("test data")
|
data := []byte("test data")
|
||||||
hash := vault.ComputeDoubleSHA256(data)
|
hash := ComputeDoubleSHA256(data)
|
||||||
|
|
||||||
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
||||||
if len(hash) != 64 {
|
if len(hash) != 64 {
|
||||||
@@ -44,166 +27,146 @@ func testComputeDoubleSHA256(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify consistency
|
// Verify consistency
|
||||||
hash2 := vault.ComputeDoubleSHA256(data)
|
hash2 := ComputeDoubleSHA256(data)
|
||||||
if hash != hash2 {
|
if hash != hash2 {
|
||||||
t.Errorf("Hash should be consistent for same input")
|
t.Errorf("Hash should be consistent for same input")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify different input produces different hash
|
// Verify different input produces different hash
|
||||||
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
|
hash3 := ComputeDoubleSHA256([]byte("different data"))
|
||||||
if hash == hash3 {
|
if hash == hash3 {
|
||||||
t.Errorf("Different input should produce different hash")
|
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()
|
|
||||||
|
|
||||||
|
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
||||||
// Test with no existing vaults
|
// Test with no existing vaults
|
||||||
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if index != 0 {
|
if index != 0 {
|
||||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a vault with metadata and matching public key (index 0; the
|
// Create a vault with metadata and matching public key
|
||||||
// family hash is the index 0 key hash)
|
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
|
||||||
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
|
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
|
// Next index for same mnemonic should be 1
|
||||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if index != 1 {
|
if index != 1 {
|
||||||
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different mnemonic should start at 0
|
// 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"
|
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||||
|
index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
|
||||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if index != 0 {
|
if index != 0 {
|
||||||
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
|
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d", index)
|
||||||
index)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add another vault with same mnemonic but higher index (5), sharing
|
// Add another vault with same mnemonic but higher index
|
||||||
// the same family hash since it's from the same mnemonic
|
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2")
|
||||||
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
|
if err := fs.MkdirAll(vaultDir2, 0o700); err != nil {
|
||||||
|
t.Fatalf("Failed to create vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Next index should be 1 (not 6): we look for the first available slot
|
// Derive identity for index 5
|
||||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if index != 1 {
|
if index != 1 {
|
||||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
t.Run("MetadataPersistence", func(t *testing.T) {
|
||||||
t.Helper()
|
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault")
|
||||||
|
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
||||||
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
|
||||||
|
|
||||||
err := fs.MkdirAll(vaultDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault directory: %v", err)
|
t.Fatalf("Failed to create vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and save metadata
|
// Create and save metadata
|
||||||
metadata := &vault.Metadata{
|
metadata := &Metadata{
|
||||||
DerivationIndex: 3,
|
DerivationIndex: 3,
|
||||||
PublicKeyHash: "test-public-key-hash",
|
PublicKeyHash: "test-public-key-hash",
|
||||||
}
|
}
|
||||||
|
|
||||||
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to save metadata: %v", err)
|
t.Fatalf("Failed to save metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and verify
|
// Load and verify
|
||||||
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
|
loaded, err := LoadVaultMetadata(fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load metadata: %v", err)
|
t.Fatalf("Failed to load metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
|
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
|
||||||
metadata.DerivationIndex, loaded.DerivationIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
|
t.Errorf("PublicKeyHash mismatch: expected %s, got %s", metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||||
metadata.PublicKeyHash, loaded.PublicKeyHash)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testDifferentKeysForDifferentIndices(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
||||||
// Derive keys with different indices
|
// Derive keys with different indices
|
||||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,26 +181,28 @@ func testDifferentKeysForDifferentIndices(t *testing.T) {
|
|||||||
// Compute public key hashes
|
// Compute public key hashes
|
||||||
pubKey0 := identity0.Recipient().String()
|
pubKey0 := identity0.Recipient().String()
|
||||||
pubKey1 := identity1.Recipient().String()
|
pubKey1 := identity1.Recipient().String()
|
||||||
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
|
hash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||||
|
|
||||||
// Verify different indices produce different public keys
|
// Verify different indices produce different public keys
|
||||||
if pubKey0 == pubKey1 {
|
if pubKey0 == pubKey1 {
|
||||||
t.Errorf("Different derivation indices should produce different public keys")
|
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
|
// But the hash of index 0's public key should be the same for the same mnemonic
|
||||||
// mnemonic. This is what we use as the identifier
|
// This is what we use as the identifier
|
||||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
pubKey0Again := identity0Again.Recipient().String()
|
pubKey0Again := identity0Again.Recipient().String()
|
||||||
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
|
hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||||
|
|
||||||
if hash0 != hash0Again {
|
if hash0 != hash0Again {
|
||||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicKeyHashConsistency(t *testing.T) {
|
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
|
// Derive identity from index 0 multiple times
|
||||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
@@ -258,8 +223,8 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute public key hashes
|
// Compute public key hashes
|
||||||
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
hash1 := ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
||||||
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
hash2 := ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
||||||
|
|
||||||
// Verify hashes are the same
|
// Verify hashes are the same
|
||||||
if hash1 != hash2 {
|
if hash1 != hash2 {
|
||||||
@@ -272,15 +237,11 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSampleHashCalculation(t *testing.T) {
|
func TestSampleHashCalculation(t *testing.T) {
|
||||||
t.Parallel()
|
// 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
|
||||||
// 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
|
|
||||||
mnemonics := []string{
|
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",
|
"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",
|
"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)
|
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("Mnemonic %d hash (index 0): %s", i, hash)
|
||||||
t.Logf(" Recipient: %s", identity.Recipient().String())
|
t.Logf(" Recipient: %s", identity.Recipient().String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkflowMismatch(t *testing.T) {
|
func TestWorkflowMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// Create a temporary directory for testing
|
// Create a temporary directory for testing
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
|
_, err := CreateVault(fs, tempDir, "default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load metadata for vault1
|
// Load metadata for vault1
|
||||||
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||||
|
metadata1, err := LoadVaultMetadata(fs, vault1Dir)
|
||||||
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -320,17 +281,20 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
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)
|
||||||
_, err = vault.CreateVault(fs, tempDir, "work", nil)
|
t.Setenv("SB_SECRET_MNEMONIC", "")
|
||||||
|
_, err = CreateVault(fs, tempDir, "work")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
|
|
||||||
// Simulate the vault import process: get the next available derivation
|
// Simulate the vault import process
|
||||||
// index for this mnemonic
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
|
||||||
|
// Get the next available derivation index for this mnemonic
|
||||||
|
derivationIndex, err := GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||||
}
|
}
|
||||||
@@ -342,12 +306,10 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||||
}
|
}
|
||||||
|
publicKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
publicKeyHash := vault.ComputeDoubleSHA256(
|
|
||||||
[]byte(identity0.Recipient().String()))
|
|
||||||
|
|
||||||
// Load existing metadata and update it (same as in VaultImport)
|
// Load existing metadata and update it (same as in VaultImport)
|
||||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
existingMetadata, err := LoadVaultMetadata(fs, vault2Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load existing metadata: %v", err)
|
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -356,13 +318,12 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
existingMetadata.DerivationIndex = derivationIndex
|
existingMetadata.DerivationIndex = derivationIndex
|
||||||
existingMetadata.PublicKeyHash = publicKeyHash
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
|
|
||||||
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
if err := SaveVaultMetadata(fs, vault2Dir, existingMetadata); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to save vault metadata: %v", err)
|
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load updated metadata for vault2
|
// Load updated metadata for vault2
|
||||||
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
metadata2, err := LoadVaultMetadata(fs, vault2Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -376,59 +337,57 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||||
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
t.Logf("SUCCESS: Both vaults have the same public key hash: %s", metadata1.PublicKeyHash)
|
||||||
metadata1.PublicKeyHash)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReverseEngineerHash(t *testing.T) {
|
func TestReverseEngineerHash(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// This is the hash that the work vault is getting in the failing test
|
// This is the hash that the work vault is getting in the failing test
|
||||||
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
||||||
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
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
|
// Calculate hash for test mnemonic
|
||||||
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive identity: %v", err)
|
t.Fatalf("Failed to derive identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
calculatedHash := vault.ComputeDoubleSHA256(
|
calculatedHash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
[]byte(identity.Recipient().String()))
|
|
||||||
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
||||||
|
|
||||||
if calculatedHash == correctHash {
|
if calculatedHash == correctHash {
|
||||||
t.Logf("Test mnemonic produces the correct hash")
|
t.Logf("✓ Test mnemonic produces the correct hash")
|
||||||
} else {
|
} else {
|
||||||
t.Errorf("Test mnemonic does not produce the correct hash")
|
t.Errorf("✗ Test mnemonic does not produce the correct hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
if calculatedHash == wrongHash {
|
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{
|
variations := []string{
|
||||||
testMnemonic,
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
" " + testMnemonic + " ",
|
" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ",
|
||||||
testMnemonic + "\n",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n",
|
||||||
strings.TrimSpace(testMnemonic),
|
strings.TrimSpace("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, variation := range variations {
|
for i, variation := range variations {
|
||||||
identity, err := agehd.DeriveIdentity(variation, 0)
|
identity, err := agehd.DeriveIdentity(variation, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Variation %d failed: %v", i, err)
|
t.Logf("Variation %d failed: %v", i, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
t.Logf("Variation %d hash: %s", i, hash)
|
t.Logf("Variation %d hash: %s", i, hash)
|
||||||
|
|
||||||
if hash == wrongHash {
|
if hash == wrongHash {
|
||||||
t.Logf("Found variation that produces wrong hash: '%s'", variation)
|
t.Logf("✗ Found variation that produces wrong hash: '%s'", variation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,15 +401,14 @@ func TestReverseEngineerHash(t *testing.T) {
|
|||||||
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
||||||
|
|
||||||
if hash == wrongHash {
|
if hash == wrongHash {
|
||||||
t.Logf("Empty mnemonic produces wrong hash!")
|
t.Logf("✗ Empty mnemonic produces wrong hash!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package vault_test
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,12 +14,14 @@ import (
|
|||||||
// validates the secret name and rejects path traversal attempts.
|
// validates the secret name and rejects path traversal attempts.
|
||||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -38,42 +41,43 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
require.Error(t, err,
|
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
|
||||||
"GetSecretVersion should reject malicious name: %s", name)
|
assert.Contains(t, err.Error(), "invalid secret name",
|
||||||
require.Contains(t, err.Error(), "invalid secret name",
|
|
||||||
"error should indicate invalid name for: %s", name)
|
"error should indicate invalid name for: %s", name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls GetSecretVersion)
|
||||||
// GetSecretVersion) also rejects path traversal names.
|
// also rejects path traversal names.
|
||||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "invalid secret name")
|
assert.Contains(t, err.Error(), "invalid secret name")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
// also validates names and rejects path traversal attempts.
|
// also validates names and rejects path traversal attempts.
|
||||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
@@ -84,11 +88,9 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := vlt.GetSecretObject(name)
|
_, err := vlt.GetSecretObject(name)
|
||||||
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
assert.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||||
require.Contains(t, err.Error(), "invalid secret name")
|
assert.Contains(t, err.Error(), "invalid secret name")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+225
-416
@@ -6,7 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,8 +21,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret listing",
|
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
|
||||||
"error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -33,15 +31,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
// Check if secrets directory exists
|
// Check if secrets directory exists
|
||||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check secrets directory",
|
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||||
"error", err, "secrets_dir", secretsDir)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secrets directory does not exist",
|
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
|
||||||
"secrets_dir", secretsDir, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -49,14 +44,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
// List directories in secrets.d
|
// List directories in secrets.d
|
||||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read secrets directory",
|
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||||
"error", err, "secrets_dir", secretsDir)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var secrets []string
|
var secrets []string
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.IsDir() {
|
if file.IsDir() {
|
||||||
// Convert storage name back to secret name
|
// Convert storage name back to secret name
|
||||||
@@ -79,7 +72,6 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
// - No leading or trailing slashes
|
// - No leading or trailing slashes
|
||||||
// - No double slashes
|
// - No double slashes
|
||||||
// - No names starting with dots
|
// - No names starting with dots
|
||||||
// - No ".." path segments
|
|
||||||
func isValidSecretName(name string) bool {
|
func isValidSecretName(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
@@ -101,9 +93,11 @@ func isValidSecretName(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal via ".." components
|
// Check for path traversal via ".." components
|
||||||
if slices.Contains(strings.Split(name, "/"), "..") {
|
for _, part := range strings.Split(name, "/") {
|
||||||
|
if part == ".." {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check the basic pattern
|
// Check the basic pattern
|
||||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
||||||
@@ -111,26 +105,10 @@ func isValidSecretName(name string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateSecretName returns an error wrapping ErrInvalidSecretName when
|
|
||||||
// name is not a valid secret name. Call it on the name exactly as the user
|
|
||||||
// gave it, before building any path from it.
|
|
||||||
func ValidateSecretName(name string) error {
|
|
||||||
if !isValidSecretName(name) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w '%s': only ASCII letters, digits, '.', '-', '_' and '/' are allowed, "+
|
|
||||||
"and a name must not be empty, start with '.' or '/', end with '/', "+
|
|
||||||
"contain '//', or have '..' as a path segment",
|
|
||||||
ErrInvalidSecretName, name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSecret adds a secret to this vault
|
// AddSecret adds a secret to this vault
|
||||||
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ErrNilValueBuffer
|
return fmt.Errorf("value buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Adding secret to vault",
|
secret.DebugWith("Adding secret to vault",
|
||||||
@@ -141,25 +119,20 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Validate secret name
|
// Validate secret name
|
||||||
err := ValidateSecretName(name)
|
if !isValidSecretName(name) {
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
return err
|
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Secret name validation passed", "secret_name", name)
|
secret.Debug("Secret name validation passed", "secret_name", name)
|
||||||
|
|
||||||
secret.Debug("Getting vault directory")
|
secret.Debug("Getting vault directory")
|
||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret addition",
|
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
||||||
"error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Convert slashes to percent signs for storage
|
// Convert slashes to percent signs for storage
|
||||||
@@ -171,72 +144,112 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
slog.String("secret_dir", secretDir),
|
slog.String("secret_dir", secretDir),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for an existing secret and the version the new one supersedes
|
// Check if secret already exists
|
||||||
exists, previousVersion, err := v.checkExistingSecret(name, secretDir, force)
|
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||||
|
exists, err := afero.DirExists(v.fs, secretDir)
|
||||||
if err != nil {
|
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
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
return v.addVersion(name, secretDir, value, previousVersion)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.addNewSecret(name, secretDir, value)
|
// 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)
|
||||||
|
|
||||||
// addNewSecret creates a secret by assembling its first version and current
|
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||||
// pointer in a temporary directory, then renaming that directory to
|
}
|
||||||
// secretDir, so an interrupted add leaves no half-made secret behind.
|
secret.Debug("Created secret directory successfully")
|
||||||
func (v *Vault) addNewSecret(
|
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
|
||||||
) error {
|
|
||||||
buildDir, err := secret.TempDirFor(v.fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
// Generate new version name
|
||||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||||
|
|
||||||
err = v.addVersion(name, buildDir, value, nil)
|
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.fs.Rename(buildDir, secretDir)
|
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move new secret into place: %w", err)
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// 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)
|
||||||
|
|
||||||
// addVersion saves value as a new version under secretDir, sets the
|
// Clean up the secret directory if this was a new secret
|
||||||
// notAfter timestamp of the version it supersedes, if any, and then points
|
if !exists {
|
||||||
// current at the new version. Until that last step, current still names the
|
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
|
||||||
// previous version, which stays readable.
|
_ = v.fs.RemoveAll(secretDir)
|
||||||
func (v *Vault) addVersion(
|
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
|
||||||
previousVersion *secret.Version,
|
|
||||||
) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Create the new version and save the encrypted value
|
|
||||||
versionName, err := v.createAndSaveVersion(
|
|
||||||
name, secretDir, value, previousVersion, &now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update previous version's notAfter timestamp if it exists
|
return fmt.Errorf("failed to save version: %w", err)
|
||||||
err = v.updatePreviousVersion(previousVersion, &now)
|
}
|
||||||
|
|
||||||
|
// Update previous version if it exists
|
||||||
|
if previousVersion != nil {
|
||||||
|
// Get long-term key to decrypt/encrypt metadata
|
||||||
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Set current symlink to new version
|
||||||
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
|
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||||
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
@@ -250,12 +263,9 @@ func (v *Vault) addVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateVersionMetadata updates the metadata of an existing version
|
// updateVersionMetadata updates the metadata of an existing version
|
||||||
func updateVersionMetadata(
|
func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity) error {
|
||||||
fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity,
|
|
||||||
) error {
|
|
||||||
// Read the version's encrypted private key
|
// Read the version's encrypted private key
|
||||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
@@ -284,70 +294,101 @@ func updateVersionMetadata(
|
|||||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||||
defer metadataBuffer.Destroy()
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer,
|
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||||
versionIdentity.Recipient())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write encrypted metadata
|
// Write encrypted metadata
|
||||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||||
|
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
|
||||||
err = secret.WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret retrieves the current version of a secret from this vault.
|
// GetSecret retrieves a secret from this vault
|
||||||
// The caller must destroy the returned buffer.
|
func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||||
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
|
|
||||||
secret.DebugWith("Getting secret from vault",
|
secret.DebugWith("Getting secret from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSecretObject validates the name and checks that the secret exists
|
return v.GetSecretVersion(name, "")
|
||||||
secretObj, err := v.GetSecretObject(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretObj.Directory)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.GetSecretVersion(name, currentVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretVersion retrieves a specific version of a secret. The version
|
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
|
||||||
// must be one of the secret's versions; GetSecret gets the current one.
|
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||||
// The caller must destroy the returned buffer.
|
|
||||||
func (v *Vault) GetSecretVersion(
|
|
||||||
name string, version string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
secret.DebugWith("Getting secret version from vault",
|
secret.DebugWith("Getting secret version from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate the name and check that the version exists
|
// Validate secret name to prevent path traversal
|
||||||
err := v.checkSecretVersion(name, version)
|
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 {
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
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
|
// Create version object
|
||||||
secretVersion := secret.NewVersion(v, name, version)
|
secretVersion := secret.NewVersion(v, name, version)
|
||||||
|
|
||||||
secret.Debug("Version exists, proceeding with vault unlock and decryption",
|
// Check if version exists
|
||||||
"version", version, "secret_name", name)
|
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)
|
// Unlock the vault (get long-term key in memory)
|
||||||
longTermIdentity, err := v.UnlockVault()
|
longTermIdentity, err := v.UnlockVault()
|
||||||
@@ -365,25 +406,34 @@ func (v *Vault) GetSecretVersion(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get the version's value
|
// Get the version's value
|
||||||
secret.Debug("About to call secretVersion.GetValue",
|
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
|
||||||
"version", version, "secret_name", name)
|
|
||||||
|
|
||||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to decrypt version value",
|
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
||||||
"error", err, "version", version, "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a copy to return since the buffer will be destroyed
|
||||||
|
result := make([]byte, decryptedValue.Size())
|
||||||
|
copy(result, decryptedValue.Bytes())
|
||||||
|
decryptedValue.Destroy()
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted secret version",
|
secret.DebugWith("Successfully decrypted secret version",
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.Int("decrypted_length", decryptedValue.Size()),
|
slog.Int("decrypted_length", len(result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return decryptedValue, nil
|
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
||||||
|
secret.Debug("Vault secret decryption debug info",
|
||||||
|
"secret_name", name,
|
||||||
|
"version", version,
|
||||||
|
"decrypted_value_length", len(result),
|
||||||
|
"is_empty", len(result) == 0)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockVault unlocks the vault and returns the long-term private key
|
// UnlockVault unlocks the vault and returns the long-term private key
|
||||||
@@ -392,8 +442,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// If vault is already unlocked, return the cached key
|
// If vault is already unlocked, return the cached key
|
||||||
if !v.Locked() {
|
if !v.Locked() {
|
||||||
secret.Debug("Vault already unlocked, returning cached long-term key",
|
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
||||||
"vault_name", v.Name)
|
|
||||||
|
|
||||||
return v.longTermKey, nil
|
return v.longTermKey, nil
|
||||||
}
|
}
|
||||||
@@ -401,8 +450,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
// Get or derive the long-term key (but don't store it yet)
|
// Get or derive the long-term key (but don't store it yet)
|
||||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get or derive long-term key",
|
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
||||||
"error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -420,9 +468,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||||
err := ValidateSecretName(name)
|
if !isValidSecretName(name) {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("invalid secret name: %s", name)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check if the secret exists by checking for the metadata file
|
// First check if the secret exists by checking for the metadata file
|
||||||
@@ -440,31 +487,27 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
return nil, fmt.Errorf("secret %s not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a Secret object
|
// Create a Secret object
|
||||||
secretObj := secret.NewSecret(v, name)
|
secretObj := secret.NewSecret(v, name)
|
||||||
|
|
||||||
// Load the metadata from disk
|
// Load the metadata from disk
|
||||||
err = secretObj.LoadMetadata()
|
if err := secretObj.LoadMetadata(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return secretObj, nil
|
return secretObj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopySecretVersion copies a single version from source into destSecretDir
|
// CopySecretVersion copies a single version from source to this vault
|
||||||
// in this vault. It decrypts the value using srcIdentity and re-encrypts
|
// It decrypts the value using srcIdentity and re-encrypts for this vault
|
||||||
// for this vault.
|
|
||||||
func (v *Vault) CopySecretVersion(
|
func (v *Vault) CopySecretVersion(
|
||||||
srcVersion *secret.Version,
|
srcVersion *secret.Version,
|
||||||
srcIdentity *age.X25519Identity,
|
srcIdentity *age.X25519Identity,
|
||||||
destSecretName string,
|
destSecretName string,
|
||||||
destSecretDir string,
|
|
||||||
destVersionName string,
|
destVersionName string,
|
||||||
) error {
|
) error {
|
||||||
secret.DebugWith("Copying secret version to vault",
|
secret.DebugWith("Copying secret version to vault",
|
||||||
@@ -483,21 +526,18 @@ func (v *Vault) CopySecretVersion(
|
|||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Load source metadata
|
// Load source metadata
|
||||||
err = srcVersion.LoadMetadata(srcIdentity)
|
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load source metadata: %w", err)
|
return fmt.Errorf("failed to load source metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination version with same name
|
// Create destination version with same name
|
||||||
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
||||||
destVersion.Directory = filepath.Join(destSecretDir, "versions", destVersionName)
|
|
||||||
|
|
||||||
// Copy metadata (preserve original timestamps)
|
// Copy metadata (preserve original timestamps)
|
||||||
destVersion.Metadata = srcVersion.Metadata
|
destVersion.Metadata = srcVersion.Metadata
|
||||||
|
|
||||||
// Save the version (encrypts to this vault's LT key)
|
// Save the version (encrypts to this vault's LT key)
|
||||||
err = destVersion.Save(valueBuffer)
|
if err := destVersion.Save(valueBuffer); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save destination version: %w", err)
|
return fmt.Errorf("failed to save destination version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,13 +571,26 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refuse to replace an existing destination secret unless forced
|
// Check if destination secret already exists
|
||||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||||
|
|
||||||
err = v.checkCopyDestination(destSecretDir, destSecretName, force)
|
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||||
if err != nil {
|
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
|
// Get source vault's long-term key
|
||||||
@@ -562,7 +615,7 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) == 0 {
|
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
|
// Get current version name
|
||||||
@@ -571,11 +624,28 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return fmt.Errorf("failed to get current version: %w", err)
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy each version and the current pointer, then move the copy into place
|
// Create destination secret directory
|
||||||
err = v.copyVersions(srcVault, srcIdentity,
|
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
|
||||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
||||||
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",
|
secret.DebugWith("Successfully copied all secret versions",
|
||||||
@@ -586,264 +656,3 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkExistingSecret reports whether the secret already exists, refuses to
|
|
||||||
// overwrite it unless force is set, and returns its current version, which
|
|
||||||
// the new version supersedes, if any.
|
|
||||||
func (v *Vault) checkExistingSecret(
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkSecretVersion validates the secret name and verifies that the secret
|
|
||||||
// exists and that version is one of its versions.
|
|
||||||
func (v *Vault) checkSecretVersion(name, version string) error {
|
|
||||||
// Validate secret name to prevent path traversal
|
|
||||||
err := ValidateSecretName(name)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if version exists
|
|
||||||
exists, err = secret.VersionExists(v.fs, secretDir, version)
|
|
||||||
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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAndSaveVersion generates a new version name, sets the version
|
|
||||||
// timestamps, and saves the encrypted value under secretDir, which is a
|
|
||||||
// temporary directory while a new secret is being assembled.
|
|
||||||
func (v *Vault) createAndSaveVersion(
|
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
|
||||||
previousVersion *secret.Version, now *time.Time,
|
|
||||||
) (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)
|
|
||||||
newVersion.Directory = filepath.Join(secretDir, "versions", 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)
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to save version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return versionName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyVersions copies each version of the source secret and its current
|
|
||||||
// pointer into a temporary directory, then moves that directory to
|
|
||||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
|
||||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
|
||||||
// temporary directory behind.
|
|
||||||
func (v *Vault) copyVersions(
|
|
||||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
|
||||||
srcSecretName, destSecretName, destSecretDir string,
|
|
||||||
versions []string, currentVersion string,
|
|
||||||
) error {
|
|
||||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
|
||||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
|
||||||
|
|
||||||
for _, versionName := range versions {
|
|
||||||
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
|
||||||
|
|
||||||
err = v.CopySecretVersion(
|
|
||||||
srcVersion, srcIdentity, destSecretName, buildDir, versionName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With --force, the secret being replaced goes only now that its
|
|
||||||
// replacement is complete
|
|
||||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check destination: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(v.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.fs.Rename(buildDir, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move copied secret into place: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
|
||||||
// is set. A secret being replaced is removed by copyVersions, once its
|
|
||||||
// replacement is complete.
|
|
||||||
func (v *Vault) checkCopyDestination(
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
//nolint:testpackage // white-box test of unexported isValidSecretName
|
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestIsValidSecretNameUppercase(t *testing.T) {
|
func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
valid bool
|
valid bool
|
||||||
@@ -36,8 +33,6 @@ func TestIsValidSecretNameUppercase(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
result := isValidSecretName(tt.name)
|
result := isValidSecretName(tt.name)
|
||||||
if result != tt.valid {
|
if result != tt.valid {
|
||||||
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, 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:
|
// Integration tests for vault-level version operations:
|
||||||
//
|
//
|
||||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper version structure
|
||||||
// version structure
|
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with force flag
|
||||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current version
|
||||||
// force flag
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter) across versions
|
||||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
|
||||||
// version
|
|
||||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
|
||||||
// across versions
|
|
||||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||||
//
|
//
|
||||||
@@ -19,7 +15,6 @@
|
|||||||
// - Promotion doesn't modify timestamps
|
// - Promotion doesn't modify timestamps
|
||||||
// - Metadata remains encrypted and intact
|
// - Metadata remains encrypted and intact
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -35,50 +30,33 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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"
|
|
||||||
|
|
||||||
// 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
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
func addTestSecretToVault(
|
func addTestSecretToVault(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||||
t *testing.T, vault *Vault, name string, value []byte, force bool,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes(value)
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
defer buffer.Destroy()
|
defer buffer.Destroy()
|
||||||
|
|
||||||
err := vault.AddSecret(name, buffer, force)
|
err := vault.AddSecret(name, buffer, force)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create a vault named "test" with its long-term key set
|
// Helper function to create a vault with long-term key set up
|
||||||
// up and unlocked
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
||||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
// Set mnemonic for testing
|
||||||
t.Helper()
|
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Create vault
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
vault, err := CreateVault(fs, stateDir, vaultName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// 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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Unlock the vault with the derived key
|
// Unlock the vault with the derived key
|
||||||
@@ -88,19 +66,19 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
|
secretName := "test/secret"
|
||||||
secretValue := []byte("initial-value")
|
secretValue := []byte("initial-value")
|
||||||
expectedValue := make([]byte, len(secretValue))
|
expectedValue := make([]byte, len(secretValue))
|
||||||
copy(expectedValue, secretValue)
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
addTestSecretToVault(t, vault, secretName, secretValue, false)
|
||||||
|
|
||||||
// Check that version directory was created
|
// Check that version directory was created
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -119,35 +97,32 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
|
|
||||||
// Get the secret value
|
// Get the secret value
|
||||||
retrievedValue, err := vault.GetSecret(testSecretPath)
|
retrievedValue, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedValue, retrievedValue)
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
|
secretName := "test/secret"
|
||||||
|
|
||||||
// Add first version
|
// 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
|
// Try to add again without force - should fail
|
||||||
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||||
defer failBuffer.Destroy()
|
defer failBuffer.Destroy()
|
||||||
|
err := vault.AddSecret(secretName, failBuffer, false)
|
||||||
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
assert.Error(t, err)
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
|
||||||
// Add with force - should create new version
|
// Add with force - should create new version
|
||||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
|
|
||||||
// Check that we have two versions
|
// Check that we have two versions
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -157,29 +132,27 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
assert.Len(t, entries, 2)
|
assert.Len(t, entries, 2)
|
||||||
|
|
||||||
// Current value should be version-2
|
// Current value should be version-2
|
||||||
value, err := vault.GetSecret(testSecretPath)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultGetSecretVersion(t *testing.T) {
|
func TestVaultGetSecretVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
|
secretName := "test/secret"
|
||||||
|
|
||||||
// Add multiple versions
|
// 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
|
// Small delay to ensure different version names
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions list
|
// Get versions list
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -190,69 +163,58 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Get specific version (first one)
|
// Get specific version (first one)
|
||||||
firstVersion := versions[1] // Last in list is first created
|
firstVersion := versions[1] // Last in list is first created
|
||||||
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
value, err := vault.GetSecretVersion(secretName, firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
defer first.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1"), first.Bytes())
|
|
||||||
|
|
||||||
// Get specific version (second one)
|
// Get specific version (second one)
|
||||||
secondVersion := versions[0] // First in list is most recent
|
secondVersion := versions[0] // First in list is most recent
|
||||||
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
value, err = vault.GetSecretVersion(secretName, secondVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
|
|
||||||
defer second.Destroy()
|
// Get current (empty version)
|
||||||
|
value, err = vault.GetSecretVersion(secretName, "")
|
||||||
assert.Equal(t, []byte("version-2"), second.Bytes())
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
|
||||||
// current one
|
|
||||||
_, err = vault.GetSecretVersion(testSecretPath, "")
|
|
||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultVersionTimestamps(t *testing.T) {
|
func TestVaultVersionTimestamps(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
// Get long-term key
|
// Get long-term key
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
secretName := "test/secret"
|
||||||
|
|
||||||
// Add first version
|
// Add first version
|
||||||
beforeFirst := time.Now()
|
beforeFirst := time.Now()
|
||||||
|
|
||||||
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||||
defer v1Buffer.Destroy()
|
defer v1Buffer.Destroy()
|
||||||
|
err = vault.AddSecret(secretName, v1Buffer, false)
|
||||||
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
afterFirst := time.Now()
|
afterFirst := time.Now()
|
||||||
|
|
||||||
// Get first version metadata
|
// Get first version metadata
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, versions, 1)
|
require.Len(t, versions, 1)
|
||||||
|
|
||||||
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
firstVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||||
err = firstVersion.LoadMetadata(ltIdentity)
|
err = firstVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check first version timestamps
|
// Check first version timestamps
|
||||||
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
||||||
assert.True(t,
|
assert.True(t, firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||||
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.Before(afterFirst.Add(time.Second)))
|
|
||||||
|
|
||||||
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
||||||
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
||||||
@@ -260,11 +222,8 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
|
|
||||||
// Add second version
|
// Add second version
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
beforeSecond := time.Now()
|
beforeSecond := time.Now()
|
||||||
|
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
||||||
|
|
||||||
afterSecond := time.Now()
|
afterSecond := time.Now()
|
||||||
|
|
||||||
// Get updated versions
|
// Get updated versions
|
||||||
@@ -273,61 +232,56 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
require.Len(t, versions, 2)
|
require.Len(t, versions, 2)
|
||||||
|
|
||||||
// Reload first version metadata (should have notAfter now)
|
// 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)
|
err = firstVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||||
assert.True(t,
|
assert.True(t, firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||||
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.Before(afterSecond.Add(time.Second)))
|
|
||||||
|
|
||||||
// Check second version timestamps
|
// Check second version timestamps
|
||||||
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||||
err = secondVersion.LoadMetadata(ltIdentity)
|
err = secondVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||||
assert.True(t,
|
assert.True(t, secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||||
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.Before(afterSecond.Add(time.Second)))
|
|
||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
addTestSecretToVault(t, vault, "test/secret", []byte("value"), false)
|
||||||
|
|
||||||
// Try to get non-existent version
|
// Try to get non-existent version
|
||||||
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
_, err := vault.GetSecretVersion("test/secret", "20991231.999")
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateVersionMetadata(t *testing.T) {
|
func TestUpdateVersionMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs)
|
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||||
|
|
||||||
// Get long-term key
|
// Get long-term key
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a version manually to test updateVersionMetadata
|
// Create a version manually to test updateVersionMetadata
|
||||||
|
secretName := "test/secret"
|
||||||
versionName := "20231215.001"
|
versionName := "20231215.001"
|
||||||
version := secret.NewVersion(vault, testSecretPath, versionName)
|
version := secret.NewVersion(vault, secretName, versionName)
|
||||||
|
|
||||||
// Set initial metadata
|
// Set initial metadata
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -338,7 +292,6 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
|||||||
// Save version
|
// Save version
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = version.Save(testBuffer)
|
err = version.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -348,7 +301,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load and verify
|
// Load and verify
|
||||||
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
version2 := secret.NewVersion(vault, secretName, versionName)
|
||||||
err = version2.LoadMetadata(ltIdentity)
|
err = version2.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
+152
-313
@@ -2,10 +2,8 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,22 +14,13 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Unlocker metadata type strings.
|
|
||||||
const (
|
|
||||||
unlockerTypePassphrase = "passphrase"
|
|
||||||
unlockerTypeSecureEnclave = "secure-enclave"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||||
//
|
|
||||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
|
||||||
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||||
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for unlocker",
|
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
||||||
"error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -41,8 +30,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
// Check if the symlink exists
|
// Check if the symlink exists
|
||||||
_, err = v.fs.Stat(currentUnlockerPath)
|
_, err = v.fs.Stat(currentUnlockerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current unlocker symlink",
|
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
||||||
"error", err, "path", currentUnlockerPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,39 +47,49 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Read unlocker metadata
|
// 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 {
|
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
|
// Create unlocker instance using direct constructors with filesystem
|
||||||
var unlocker secret.Unlocker
|
var unlocker secret.Unlocker
|
||||||
// Use metadata directly as it's already the correct type
|
// Use metadata directly as it's already the correct type
|
||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case unlockerTypePassphrase:
|
case "passphrase":
|
||||||
secret.Debug("Creating passphrase unlocker instance",
|
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
||||||
"unlocker_type", metadata.Type)
|
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
|
||||||
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
|
||||||
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
|
||||||
unlocker = passphraseUnlocker
|
|
||||||
case "pgp":
|
case "pgp":
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "keychain":
|
case "keychain":
|
||||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case unlockerTypeSecureEnclave:
|
case "secure-enclave":
|
||||||
secret.Debug("Creating secure enclave unlocker instance",
|
secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type)
|
||||||
"unlocker_type", metadata.Type)
|
|
||||||
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
||||||
default:
|
default:
|
||||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedUnlockerType, metadata.Type)
|
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Successfully created unlocker instance",
|
secret.DebugWith("Successfully created unlocker instance",
|
||||||
@@ -103,16 +101,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return unlocker, nil
|
return unlocker, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
// resolveUnlockerDirectory reads the current-unlocker file to get the unlocker directory path
|
||||||
// unlocker directory path
|
// The file contains just the unlocker name (e.g., "passphrase")
|
||||||
// The file contains just the name of the unlocker's directory in unlockers.d
|
|
||||||
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||||
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
||||||
|
|
||||||
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read current-unlocker file",
|
secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath)
|
||||||
"error", err, "path", currentUnlockerPath)
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -129,51 +125,51 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
|||||||
return absolutePath, nil
|
return absolutePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
|
||||||
// instance and its directory path. A directory that ListUnlockers skips is
|
func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) {
|
||||||
// skipped here too, with the same warning. Such a directory has no ID: if
|
|
||||||
// no unlocker has the ID unlockerID but such a directory is named
|
|
||||||
// unlockerID, that directory is returned with a nil unlocker, so that
|
|
||||||
// RemoveUnlocker can remove it.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
|
||||||
func (v *Vault) findUnlockerByID(
|
|
||||||
unlockersDir, unlockerID string,
|
|
||||||
) (secret.Unlocker, string, error) {
|
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
skippedDirPath := ""
|
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if !file.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
// Read metadata file
|
||||||
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
if !ok {
|
if err != nil {
|
||||||
if file.Name() == unlockerID {
|
return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||||
skippedDirPath = unlockerDirPath
|
|
||||||
}
|
}
|
||||||
|
if !exists {
|
||||||
|
// Skip directories without metadata - they might not be unlockers
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
// Create the appropriate unlocker instance
|
||||||
var tempUnlocker secret.Unlocker
|
var tempUnlocker secret.Unlocker
|
||||||
|
|
||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case unlockerTypePassphrase:
|
case "passphrase":
|
||||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "pgp":
|
case "pgp":
|
||||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "keychain":
|
case "keychain":
|
||||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case unlockerTypeSecureEnclave:
|
case "secure-enclave":
|
||||||
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -185,7 +181,7 @@ func (v *Vault) findUnlockerByID(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, skippedDirPath, nil
|
return nil, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns a list of available unlockers for this vault
|
||||||
@@ -202,7 +198,6 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []UnlockerMetadata{}, nil
|
return []UnlockerMetadata{}, nil
|
||||||
}
|
}
|
||||||
@@ -214,14 +209,30 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var unlockers []UnlockerMetadata
|
var unlockers []UnlockerMetadata
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if file.IsDir() {
|
||||||
|
// Read metadata file
|
||||||
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||||
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return 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())
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
if ok {
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers = append(unlockers, metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,72 +240,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return unlockers, nil
|
return unlockers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
|
// RemoveUnlocker removes an unlocker from this vault
|
||||||
// name in unlockersDir. If the metadata file cannot be checked for, is
|
|
||||||
// missing, or cannot be read or parsed, it warns, naming the directory,
|
|
||||||
// and returns false: the caller skips that directory.
|
|
||||||
func (v *Vault) readUnlockerMetadataOrWarn(
|
|
||||||
unlockersDir, name string,
|
|
||||||
) (UnlockerMetadata, bool) {
|
|
||||||
metadataPath := filepath.Join(unlockersDir, name, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
secret.Warn("Skipping unlocker directory with missing metadata file",
|
|
||||||
"directory", name)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasUnlocker reports whether RemoveUnlocker finds something to remove by
|
|
||||||
// the ID unlockerID: an unlocker with that ID, or an unlocker directory of
|
|
||||||
// that name that ListUnlockers skips.
|
|
||||||
func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
|
|
||||||
vaultDir, err := v.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, unlockerDir, err := v.findUnlockerByID(
|
|
||||||
filepath.Join(vaultDir, "unlockers.d"), unlockerID)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return unlockerDir != "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
|
||||||
// directory that ListUnlockers skips is removed by its directory name; its
|
|
||||||
// type is unknown, so only the directory is removed.
|
|
||||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,17 +251,13 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlockerDir == "" {
|
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the unlocker's Remove method
|
// Use the unlocker's Remove method
|
||||||
@@ -333,230 +275,127 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A directory found without an unlocker is one ListUnlockers skips; it
|
if targetUnlockerDir == "" {
|
||||||
// cannot be selected.
|
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
||||||
if unlocker == nil {
|
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or replace the current-unlocker file with just the unlocker
|
// Create/update current-unlocker file with just the unlocker name
|
||||||
// name. It is replaced in one rename, so it never goes missing.
|
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
|
// Remove existing file if it exists
|
||||||
|
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||||
|
} else if exists {
|
||||||
|
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get just the unlocker name (basename of the directory)
|
// Get just the unlocker name (basename of the directory)
|
||||||
unlockerName := filepath.Base(targetUnlockerDir)
|
unlockerName := filepath.Base(targetUnlockerDir)
|
||||||
|
|
||||||
|
// Write just the unlocker name to the file
|
||||||
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||||
|
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil {
|
||||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath, []byte(unlockerName))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
// directory of its own, makes it the current unlocker, and only then removes
|
|
||||||
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
|
|
||||||
// point leaves a complete current unlocker, the old one or the new.
|
|
||||||
// The passphrase must be provided as a LockedBuffer for security
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
func (v *Vault) CreatePassphraseUnlocker(
|
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
|
||||||
passphrase *memguard.LockedBuffer,
|
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to get the long-term key (either from memory if unlocked, or
|
// Create unlocker directory
|
||||||
// derive it). Getting it before anything is written means failing to
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
||||||
// get it changes nothing.
|
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
// The passphrase unlockers the new one replaces
|
|
||||||
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt := time.Now()
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
|
|
||||||
createdAt.UTC().Format(secret.UnlockerTimeFormat))
|
|
||||||
|
|
||||||
// Generate new age keypair for unlocker
|
// Generate new age keypair for unlocker
|
||||||
unlockerIdentity, err := age.GenerateX25519Identity()
|
unlockerIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt long-term private key to this unlocker
|
// Write public key
|
||||||
ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity)
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
defer ltPrivKeyBuffer.Destroy()
|
if err := afero.WriteFile(v.fs, pubKeyPath,
|
||||||
|
[]byte(unlockerIdentity.Recipient().String()),
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
|
secret.FilePerms); err != nil {
|
||||||
unlockerIdentity.Recipient())
|
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encrypt long-term private 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, 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{
|
metadata := UnlockerMetadata{
|
||||||
Type: unlockerTypePassphrase,
|
Type: "passphrase",
|
||||||
CreatedAt: createdAt,
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{},
|
Flags: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the unlocker's files, the metadata last
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
|
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||||
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
encryptedLtPrivKey, metadataBytes)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the new unlocker by its directory, not by its ID: an old
|
// Encrypt long-term private key to this unlocker
|
||||||
// passphrase unlocker created in the same minute has the same ID.
|
// We need to get the long-term key (either from memory if unlocked, or derive it)
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
|
|
||||||
[]byte(filepath.Base(unlockerDir)))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
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")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the unlocker instance
|
||||||
|
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
|
||||||
|
// Select this unlocker as current
|
||||||
|
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
||||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, oldDir := range oldDirs {
|
return unlocker, nil
|
||||||
err = secret.RemoveDirAtomic(v.fs, oldDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"created and selected the new passphrase unlocker: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
|
|
||||||
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
|
|
||||||
// same warning.
|
|
||||||
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
|
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dirs []string
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
|
||||||
if ok && metadata.Type == unlockerTypePassphrase {
|
|
||||||
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dirs, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeUnlockerFiles writes the files of a passphrase unlocker into
|
|
||||||
// unlockerDir: its public key, its passphrase-encrypted private key, the
|
|
||||||
// long-term private key encrypted to it, and its metadata, last.
|
|
||||||
func (v *Vault) writeUnlockerFiles(
|
|
||||||
unlockerDir string,
|
|
||||||
unlockerIdentity *age.X25519Identity,
|
|
||||||
passphrase *memguard.LockedBuffer,
|
|
||||||
encryptedLtPrivKey, metadataBytes []byte,
|
|
||||||
) error {
|
|
||||||
// Write public key
|
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
|
|
||||||
[]byte(unlockerIdentity.Recipient().String()))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt private key with passphrase
|
|
||||||
privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity)
|
|
||||||
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 = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
|
||||||
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
|
||||||
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-159
@@ -3,12 +3,12 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,26 +18,17 @@ type Vault struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
||||||
// Mnemonic, when not nil, is what the long-term key is derived from
|
|
||||||
// instead of the current unlocker. The caller destroys it.
|
|
||||||
Mnemonic *memguard.LockedBuffer
|
|
||||||
// UnlockPassphrase, when not nil, is given to the current unlocker
|
|
||||||
// when that is a passphrase unlocker, which otherwise prompts for it.
|
|
||||||
// The caller destroys it.
|
|
||||||
UnlockPassphrase *memguard.LockedBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVault creates a new Vault instance
|
// NewVault creates a new Vault instance
|
||||||
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
||||||
secret.Debug("Creating NewVault instance")
|
secret.Debug("Creating NewVault instance")
|
||||||
|
|
||||||
v := &Vault{
|
v := &Vault{
|
||||||
Name: name,
|
Name: name,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: stateDir,
|
stateDir: stateDir,
|
||||||
longTermKey: nil,
|
longTermKey: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Created NewVault instance successfully")
|
secret.Debug("Created NewVault instance successfully")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
@@ -63,20 +54,7 @@ func (v *Vault) ClearLongTermKey() {
|
|||||||
v.longTermKey = nil
|
v.longTermKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMnemonic sets v.Mnemonic, for code that has v only as a
|
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
|
||||||
// secret.VaultInterface.
|
|
||||||
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
|
||||||
v.Mnemonic = mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
|
|
||||||
// a secret.VaultInterface.
|
|
||||||
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
|
|
||||||
v.UnlockPassphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
|
||||||
// from available sources
|
|
||||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
// If we have it in memory, return it
|
// If we have it in memory, return it
|
||||||
if !v.Locked() {
|
if !v.Locked() {
|
||||||
@@ -85,13 +63,57 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
||||||
|
|
||||||
if v.Mnemonic != nil {
|
// Try to derive from environment mnemonic first
|
||||||
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); 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
|
// No mnemonic available, try to use current unlocker
|
||||||
secret.Debug("No mnemonic available, using current unlocker to unlock vault",
|
secret.Debug("No mnemonic available, using current unlocker to unlock vault", "vault_name", v.Name)
|
||||||
"vault_name", v.Name)
|
|
||||||
|
|
||||||
// Get current unlocker
|
// Get current unlocker
|
||||||
unlocker, err := v.GetCurrentUnlocker()
|
unlocker, err := v.GetCurrentUnlocker()
|
||||||
@@ -129,135 +151,10 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
return ltIdentity, nil
|
return ltIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDirectory returns the vault's directory path
|
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
|
||||||
func (v *Vault) GetDirectory() (string, error) {
|
// SE unlockers decrypt the long-term key directly; other unlockers use an intermediate identity.
|
||||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity, error) {
|
||||||
}
|
if unlocker.GetType() == "secure-enclave" {
|
||||||
|
|
||||||
// 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, err := afero.DirExists(v.fs, secretsDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
|
|
||||||
secretsDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
mnemonic string,
|
|
||||||
) (*age.X25519Identity, error) {
|
|
||||||
secret.Debug("Using mnemonic 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(mnemonic, 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 {
|
|
||||||
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
||||||
|
|
||||||
ltIdentity, err := unlocker.GetIdentity()
|
ltIdentity, err := unlocker.GetIdentity()
|
||||||
@@ -281,8 +178,7 @@ func (v *Vault) unlockLongTermKey(
|
|||||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
|
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||||
encryptedLtPrivKey, unlockerIdentity)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -295,3 +191,59 @@ func (v *Vault) unlockLongTermKey(
|
|||||||
|
|
||||||
return ltIdentity, nil
|
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) {
|
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create in-memory filesystem
|
// Create in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create a vault directory without a public key (simulating the error
|
// Create a vault directory without a public key (simulating the error condition)
|
||||||
// condition)
|
vaultDir := filepath.Join(stateDir, "vaults.d", "broken")
|
||||||
vaultDir := filepath.Join(testStateDir, "vaults.d", "broken")
|
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
// Create currentvault symlink
|
// Create currentvault symlink
|
||||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
|
||||||
|
|
||||||
// Create vault instance
|
// Create vault instance
|
||||||
vlt := vault.NewVault(fs, testStateDir, "broken")
|
vlt := vault.NewVault(fs, stateDir, "broken")
|
||||||
|
|
||||||
// Try to add a secret - this should fail
|
// Try to add a secret - this should fail
|
||||||
|
secretName := "test-secret"
|
||||||
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer value.Destroy()
|
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")
|
require.Error(t, err, "AddSecret should fail when public key is missing")
|
||||||
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
||||||
|
|
||||||
// Verify that the secret directory was NOT created
|
// 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)
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
|
||||||
@@ -49,51 +47,41 @@ func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
|||||||
if exists, _ := afero.DirExists(fs, secretsDir); exists {
|
if exists, _ := afero.DirExists(fs, secretsDir); exists {
|
||||||
entries, err := afero.ReadDir(fs, secretsDir)
|
entries, err := afero.ReadDir(fs, secretsDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, entries,
|
assert.Empty(t, entries, "secrets.d directory should be empty after failed AddSecret")
|
||||||
"secrets.d directory should be empty after failed AddSecret")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create in-memory filesystem
|
// Create in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create a vault directory with public key
|
// 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))
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
// Create a mock public key that will cause encryption to fail
|
// Create a mock public key that will cause encryption to fail
|
||||||
// by using an invalid age public key format
|
// by using an invalid age public key format
|
||||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"), secret.FilePerms))
|
||||||
afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"),
|
|
||||||
secret.FilePerms))
|
|
||||||
|
|
||||||
// Create currentvault symlink
|
// Create currentvault symlink
|
||||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
|
||||||
|
|
||||||
// Create vault instance
|
// 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
|
// Try to add a secret - this should fail during encryption
|
||||||
|
secretName := "test-secret"
|
||||||
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer value.Destroy()
|
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")
|
require.Error(t, err, "AddSecret should fail with invalid public key")
|
||||||
|
|
||||||
// Verify that the secret directory was NOT created
|
// 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)
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
|
||||||
// Nor is the temporary directory the secret was assembled in left behind
|
|
||||||
entries, err := afero.ReadDir(fs, vaultDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.Equal(t, "pub.age", entries[0].Name())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-114
@@ -1,84 +1,36 @@
|
|||||||
package vault_test
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // order-dependent subtests forbid parallel
|
|
||||||
func TestVaultOperations(t *testing.T) {
|
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, "test-passphrase")
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
|
// Test vault creation
|
||||||
t.Run("CreateVault", func(t *testing.T) {
|
t.Run("CreateVault", func(t *testing.T) {
|
||||||
testCreateVault(t, fs)
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if vlt.GetName() != testVaultName {
|
if vlt.GetName() != "test-vault" {
|
||||||
t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
|
t.Errorf("Expected vault name 'test-vault', got '%s'", vlt.GetName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check vault directory exists
|
// Check vault directory exists
|
||||||
@@ -95,45 +47,49 @@ func testCreateVault(t *testing.T, fs afero.Fs) {
|
|||||||
if !exists {
|
if !exists {
|
||||||
t.Errorf("Vault directory should exist")
|
t.Errorf("Vault directory should exist")
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testListVaults(t *testing.T, fs afero.Fs) {
|
// Test vault listing
|
||||||
t.Helper()
|
t.Run("ListVaults", func(t *testing.T) {
|
||||||
|
vaults, err := ListVaults(fs, stateDir)
|
||||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to list vaults: %v", err)
|
t.Fatalf("Failed to list vaults: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(vaults, testVaultName) {
|
found := false
|
||||||
t.Errorf("Expected to find '%s' in vault list", testVaultName)
|
for _, vault := range vaults {
|
||||||
|
if vault == "test-vault" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func testSelectVault(t *testing.T, fs afero.Fs) {
|
if !found {
|
||||||
t.Helper()
|
t.Errorf("Expected to find 'test-vault' in vault list")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
err := vault.SelectVault(fs, testStateDir, testVaultName)
|
// Test vault selection
|
||||||
|
t.Run("SelectVault", func(t *testing.T) {
|
||||||
|
err := SelectVault(fs, stateDir, "test-vault")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to select vault: %v", err)
|
t.Fatalf("Failed to select vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test getting current vault
|
// Test getting current vault
|
||||||
currentVault, err := vault.GetCurrentVault(fs, testStateDir)
|
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentVault.GetName() != testVaultName {
|
if currentVault.GetName() != "test-vault" {
|
||||||
t.Errorf("Expected current vault '%s', got '%s'",
|
t.Errorf("Expected current vault 'test-vault', got '%s'", currentVault.GetName())
|
||||||
testVaultName, currentVault.GetName())
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testSecretOperations(t *testing.T, fs afero.Fs) {
|
// Test secret operations
|
||||||
t.Helper()
|
t.Run("SecretOperations", func(t *testing.T) {
|
||||||
|
vlt, err := GetCurrentVault(fs, stateDir)
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -155,7 +111,6 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|||||||
|
|
||||||
// Write the correct public key to the pub.age file
|
// Write the correct public key to the pub.age file
|
||||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||||
@@ -184,7 +139,15 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|||||||
t.Fatalf("Failed to list secrets: %v", err)
|
t.Fatalf("Failed to list secrets: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(secrets, secretName) {
|
found := false
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if secret == secretName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
t.Errorf("Expected to find secret '%s' in list", secretName)
|
t.Errorf("Expected to find secret '%s' in list", secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +156,15 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get secret: %v", err)
|
t.Fatalf("Failed to get secret: %v", err)
|
||||||
}
|
}
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Expected secret value '%s', got '%s'",
|
t.Errorf("Expected secret value '%s', got '%s'", string(expectedValue), string(retrievedValue))
|
||||||
expectedValue, retrievedValue.Bytes())
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testNumSecrets(t *testing.T, fs afero.Fs) {
|
// Test NumSecrets
|
||||||
t.Helper()
|
t.Run("NumSecrets", func(t *testing.T) {
|
||||||
|
vlt, err := GetCurrentVault(fs, stateDir)
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -218,19 +178,16 @@ func testNumSecrets(t *testing.T, fs afero.Fs) {
|
|||||||
if numSecrets != 1 {
|
if numSecrets != 1 {
|
||||||
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
// Test unlocker operations
|
||||||
t.Helper()
|
t.Run("UnlockerOperations", func(t *testing.T) {
|
||||||
|
vlt, err := GetCurrentVault(fs, stateDir)
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test vault unlocking (should happen automatically via mnemonic)
|
// Test vault unlocking (should happen automatically via mnemonic)
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
if vlt.Locked() {
|
if vlt.Locked() {
|
||||||
_, err := vlt.UnlockVault()
|
_, err := vlt.UnlockVault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,9 +196,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a passphrase unlocker
|
// Create a passphrase unlocker
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
@@ -259,11 +215,9 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
|
|
||||||
// Check key type
|
// Check key type
|
||||||
keyFound := false
|
keyFound := false
|
||||||
|
|
||||||
for _, key := range unlockers {
|
for _, key := range unlockers {
|
||||||
if key.Type == "passphrase" {
|
if key.Type == "passphrase" {
|
||||||
keyFound = true
|
keyFound = true
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,28 +239,30 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
||||||
t.Errorf("Expected current unlocker ID '%s', got '%s'",
|
t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
||||||
passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
// Set test environment variables
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
stateDir := "/test/state"
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a passphrase unlocker so we have at least one valid unlocker
|
// 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()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
@@ -317,9 +273,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
||||||
|
|
||||||
err = fs.MkdirAll(bogusDir, 0o700)
|
err = fs.MkdirAll(bogusDir, 0o700)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create bogus directory: %v", err)
|
t.Fatalf("Failed to create bogus directory: %v", err)
|
||||||
@@ -328,8 +282,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
// ListUnlockers should succeed, skipping the bogus directory
|
// ListUnlockers should succeed, skipping the bogus directory
|
||||||
unlockers, err := vlt.ListUnlockers()
|
unlockers, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListUnlockers returned error when it should have skipped "+
|
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
|
||||||
"bad directory: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should still have the valid passphrase unlocker
|
// Should still have the valid passphrase unlocker
|
||||||
|
|||||||
+1
-10
@@ -9,7 +9,6 @@
|
|||||||
package agehd
|
package agehd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -29,10 +28,6 @@ const (
|
|||||||
x25519KeySize = 32 // 256-bit key size for X25519
|
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.
|
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
||||||
func clamp(k []byte) {
|
func clamp(k []byte) {
|
||||||
k[0] &= 248
|
k[0] &= 248
|
||||||
@@ -44,7 +39,7 @@ func clamp(k []byte) {
|
|||||||
// *age.X25519Identity by round-tripping through Bech32.
|
// *age.X25519Identity by round-tripping through Bech32.
|
||||||
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||||
if len(ent) != x25519KeySize {
|
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
|
// 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
|
bech32BitSize8 = 8 // Standard 8-bit encoding
|
||||||
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
||||||
)
|
)
|
||||||
|
|
||||||
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
return nil, fmt.Errorf("bech32 convert: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := bech32.Encode(hrp, data)
|
s, err := bech32.Encode(hrp, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 encode: %w", err)
|
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
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, x25519KeySize)
|
key := make([]byte, x25519KeySize)
|
||||||
|
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
@@ -124,7 +116,6 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
|
|||||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, x25519KeySize)
|
key := make([]byte, x25519KeySize)
|
||||||
|
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
|
|||||||
+311
-293
File diff suppressed because it is too large
Load Diff
+30
-103
@@ -9,7 +9,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,10 +23,10 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
|
// 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 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 is the application number for BIP39 mnemonics
|
||||||
AppBIP39 = 39
|
AppBIP39 = 39
|
||||||
@@ -35,50 +34,18 @@ const (
|
|||||||
AppHDWIF = 2
|
AppHDWIF = 2
|
||||||
// AppXPRV is the application number for extended private key
|
// AppXPRV is the application number for extended private key
|
||||||
AppXPRV = 32
|
AppXPRV = 32
|
||||||
APP_HEX = 128169 //nolint:revive // BIP85 spec naming
|
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // BIP85 spec naming
|
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
AppPWD85 = 707785 // Base85 passwords
|
AppPWD85 = 707785 // Base85 passwords
|
||||||
APP_RSA = 828365 //nolint:revive // BIP85 spec naming
|
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||||
)
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version bytes for extended keys
|
// Version bytes for extended keys
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // standard BIP32 version constants
|
|
||||||
var (
|
var (
|
||||||
// MainNetPrivateKey is the version for mainnet private keys
|
// MainNetPrivateKey is the version for mainnet private keys
|
||||||
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4}
|
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||||
// TestNetPrivateKey is the version for testnet private keys
|
// TestNetPrivateKey is the version for testnet private keys
|
||||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94}
|
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||||
)
|
)
|
||||||
|
|
||||||
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
// 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
|
// 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)
|
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) {
|
func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
||||||
// Validate the masterKey is a private key
|
// Validate the masterKey is a private key
|
||||||
if !masterKey.IsPrivate() {
|
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
|
// 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
|
return ecPrivKey.Serialize(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the
|
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the BIP85 method
|
||||||
// BIP85 method
|
func DeriveBIP85Entropy(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
||||||
func DeriveBIP85Entropy(
|
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
|
||||||
path string,
|
|
||||||
) ([]byte, error) {
|
|
||||||
// Get the child key bytes
|
// Get the child key bytes
|
||||||
privKeyBytes, err := DeriveChildKey(masterKey, path)
|
privKeyBytes, err := DeriveChildKey(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,10 +115,7 @@ func DeriveBIP85Entropy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deriveChildKey derives a child key from a parent key using the given path
|
// deriveChildKey derives a child key from a parent key using the given path
|
||||||
func deriveChildKey(
|
func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.ExtendedKey, error) {
|
||||||
parent *hdkeychain.ExtendedKey,
|
|
||||||
path string,
|
|
||||||
) (*hdkeychain.ExtendedKey, error) {
|
|
||||||
if path == "" || path == "m" || path == "/" {
|
if path == "" || path == "m" || path == "/" {
|
||||||
return parent, nil
|
return parent, nil
|
||||||
}
|
}
|
||||||
@@ -181,12 +141,9 @@ func deriveChildKey(
|
|||||||
|
|
||||||
// Parse the index
|
// Parse the index
|
||||||
var index uint32
|
var index uint32
|
||||||
|
|
||||||
_, err := fmt.Sscanf(component, "%d", &index)
|
_, err := fmt.Sscanf(component, "%d", &index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("invalid path component: %s", component)
|
||||||
"%w: %s", ErrInvalidPathComponent, component,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply hardening if needed
|
// Apply hardening if needed
|
||||||
@@ -207,14 +164,8 @@ func deriveChildKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
||||||
func DeriveBIP39Entropy(
|
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, AppBIP39, language, words, index)
|
||||||
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)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,7 +183,6 @@ func DeriveBIP39Entropy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
var bits int
|
var bits int
|
||||||
|
|
||||||
switch words {
|
switch words {
|
||||||
case words12:
|
case words12:
|
||||||
bits = 128
|
bits = 128
|
||||||
@@ -245,7 +195,7 @@ func DeriveBIP39Entropy(
|
|||||||
case words24:
|
case words24:
|
||||||
bits = 256
|
bits = 256
|
||||||
default:
|
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)
|
// 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
|
// Convert to WIF format
|
||||||
privKey, _ := btcec.PrivKeyFromBytes(keyBytes)
|
privKey, _ := btcec.PrivKeyFromBytes(keyBytes)
|
||||||
|
|
||||||
wif, err := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) // compressed=true
|
wif, err := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) // compressed=true
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create WIF: %w", err)
|
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)
|
// DeriveXPRV derives an extended private key (XPRV)
|
||||||
func DeriveXPRV(
|
func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) {
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
|
||||||
index uint32,
|
|
||||||
) (*hdkeychain.ExtendedKey, error) {
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
@@ -320,10 +266,10 @@ func DeriveXPRV(
|
|||||||
checksum := doubleSHA256(serializedBytes)[:4]
|
checksum := doubleSHA256(serializedBytes)[:4]
|
||||||
|
|
||||||
// Append checksum
|
// Append checksum
|
||||||
serializedBytes = append(serializedBytes, checksum...)
|
serializedWithChecksum := append(serializedBytes, checksum...)
|
||||||
|
|
||||||
// Base58 encode
|
// Base58 encode
|
||||||
xprvStr := base58.Encode(serializedBytes)
|
xprvStr := base58.Encode(serializedWithChecksum)
|
||||||
|
|
||||||
// Parse the serialized xprv back to an ExtendedKey
|
// Parse the serialized xprv back to an ExtendedKey
|
||||||
return hdkeychain.NewKeyFromString(xprvStr)
|
return hdkeychain.NewKeyFromString(xprvStr)
|
||||||
@@ -338,12 +284,9 @@ func doubleSHA256(data []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveHex derives a raw hex string of specified length
|
// DeriveHex derives a raw hex string of specified length
|
||||||
func DeriveHex(
|
func DeriveHex(masterKey *hdkeychain.ExtendedKey, numBytes, index uint32) (string, error) {
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
|
||||||
numBytes, index uint32,
|
|
||||||
) (string, error) {
|
|
||||||
if numBytes < 16 || numBytes > 64 {
|
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)
|
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
|
// DeriveBase64Password derives a password encoded in Base64
|
||||||
func DeriveBase64Password(
|
func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
|
||||||
pwdLen, index uint32,
|
|
||||||
) (string, error) {
|
|
||||||
if pwdLen < 20 || pwdLen > 86 {
|
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)
|
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
|
// Slice to the desired password length
|
||||||
if len(encodedStr) < int(pwdLen) {
|
if len(encodedStr) < int(pwdLen) {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
||||||
"derived password length %d %w %d",
|
|
||||||
len(encodedStr), ErrPasswordTooShort, pwdLen,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodedStr[:pwdLen], nil
|
return encodedStr[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBase85Password derives a password encoded in Base85
|
// DeriveBase85Password derives a password encoded in Base85
|
||||||
func DeriveBase85Password(
|
func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
||||||
masterKey *hdkeychain.ExtendedKey,
|
|
||||||
pwdLen, index uint32,
|
|
||||||
) (string, error) {
|
|
||||||
if pwdLen < 10 || pwdLen > 80 {
|
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)
|
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
|
// Slice to the desired password length
|
||||||
if len(encoded) < int(pwdLen) {
|
if len(encoded) < int(pwdLen) {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
||||||
"encoded length %d %w %d",
|
|
||||||
len(encoded), ErrEncodedTooShort, pwdLen,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return encoded[:pwdLen], nil
|
return encoded[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeBase85WithRFC1924Charset encodes data using Base85 with the
|
// encodeBase85WithRFC1924Charset encodes data using Base85 with the RFC1924 character set
|
||||||
// RFC1924 character set
|
|
||||||
func encodeBase85WithRFC1924Charset(data []byte) string {
|
func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||||
// RFC1924 character set
|
// RFC1924 character set
|
||||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||||
"abcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
base85ChunkSize = 4 // Process 4 bytes at a time
|
base85ChunkSize = 4 // Process 4 bytes at a time
|
||||||
@@ -440,9 +369,7 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
|
|||||||
copy(padded, data)
|
copy(padded, data)
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
|
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
|
||||||
// Each 4 bytes becomes 5 Base85 characters
|
|
||||||
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize)
|
|
||||||
|
|
||||||
// Process in 4-byte chunks
|
// Process in 4-byte chunks
|
||||||
for i := 0; i < len(padded); i += base85ChunkSize {
|
for i := 0; i < len(padded); i += base85ChunkSize {
|
||||||
|
|||||||
+451
-518
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user