Author SHA1 Message Date
clawbot 66a0714f92 Read secret environment variables once per command, then unset them (closes #60)
check / check (push) Waiting to run
init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both;
CreatePGPUnlocker sets them on the vault it loads through SetMnemonic
and SetUnlockPassphrase, new in VaultInterface. README warns against
both variables.

Model: opus-5-5
2026-10-04 13:06:03 +00:00
clawbot 62967f28d0 Make secret unlocker add pgp work on Linux (closes #88)
check / check (push) Waiting to run
CreatePGPUnlocker got the vault's long-term key from the keychain
unlocker's helper, which on every platform but macOS is a stub that
always fails. It now calls the vault's GetOrDeriveLongTermKey, as adding
a passphrase unlocker does: from the mnemonic, checked against the
vault, or else from the current unlocker. That method joins
VaultInterface. The test GPG key gains an encryption subkey, and a new
test adds a PGP unlocker with the long-term key from the mnemonic and
from a passphrase unlocker, then reads a secret through it.

Model: opus-5-5
2026-10-04 14:42:01 +02:00
clawbot 007254a1f0 Check vault names in every command that takes one (closes #68)
check / check (push) Waiting to run
A vault name may use only lowercase ASCII letters, digits, `.`, `-` and
`_`, and must not be empty, `.` or `..`; the error now states that 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 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.

Model: opus-5-5
2026-10-04 13:58:46 +02:00
clawbot eb596b8be6 Run the checks again on every script/cibuild (closes #54)
check / check (push) Waiting to run
On an unchanged tree docker served every check step of the Dockerfile
from its build cache, so a second script/cibuild ran no lint, tests or
build and still succeeded.

script/cibuild now passes the current time as the CHECK_EPOCH build
argument. The lint and build stages each declare it after their module
download and before `COPY . .`. A build argument whose value changes
makes every RUN step after its declaration miss the cache, so the
checks run on each build while the base images, the apk install and the
module downloads stay cached.

Model: opus-5-5
2026-10-04 13:25:24 +02:00
clawbot 596b978cb1 Leave no partial unlocker directory when adding an unlocker fails (closes #48)
check / check (push) Waiting to run
CreatePGPUnlocker looked up the GPG key's fingerprint, and the keychain
unlocker got the long-term key, only after writing part of the unlocker,
so a failure there left a directory with no metadata. Both now do every
step that can fail before writing anything. `secret unlocker add pgp`
looks the fingerprint up once, for its duplicate check, and passes it to
CreatePGPUnlocker to record. All four unlocker types write their files
through the new secret.WriteDir, which builds a new directory in a
temporary directory, renames it into place when complete and removes it
on a failure. A directory that already exists, as when an unlocker
replaces one of the same name, is written in place and never removed.

Model: opus-5-5
2026-10-04 12:58:50 +02:00
clawbot 24d99819a3 Skip corrupt unlocker metadata in unlocker select and remove (closes #72)
check / check (push) Waiting to run
findUnlockerByID failed on the first unlocker directory whose metadata
could not be checked, read or parsed, so `secret unlocker select` and
`remove` failed when one sorted before the unlocker asked for. It now
skips such a directory with the warning ListUnlockers gives, through
the code both now share. A skipped directory is removed by its
directory name, with RemoveDirAtomic, and cannot be selected. 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 does, since it may be the only working unlocker.

Model: opus-5-5
2026-10-04 12:42:01 +02:00
clawbot 1ec0423e6e Print usage only for a command called wrongly (closes #41)
check / check (push) Waiting to run
A failed command printed the whole usage text after its error, burying
it. The root command's PersistentPreRunE now turns usage off, so an
error from running the command is printed once on its own. Wrong arity,
an unknown flag, a bad flag value, a missing required flag and broken
flag groups still get usage: 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 was not used: in
this cobra version it hides usage for argument and flag errors too.
Cobra still prints the error; Entry is unchanged.

Model: opus-5-5
2026-10-04 12:08:06 +02:00
clawbot 4ed77902d1 Keep secret get values in locked memory (closes #37)
check / check (push) Waiting to run
Vault.GetSecret and Vault.GetSecretVersion return the decrypted value
as a *memguard.LockedBuffer instead of copying it into an ordinary
[]byte that nothing wiped. Every caller destroys the buffer, and
`secret get` writes its bytes straight to stdout, still with no
trailing newline. Instance.Print, which formatted through fmt and had
no other callers, is removed, and so is a debug log line in
`get --version` that held the plaintext value.

Model: opus-5-5
2026-10-04 11:16:23 +02:00
clawbot 00713b8677 Build with the local docker daemon; add script/build (closes #44)
check / check (push) Waiting to run
The Makefile exported DOCKER_HOST pointing at one private machine, so
every docker call made through make, `make lint` and `make check`
included, failed everywhere else. The line is gone: docker uses the
local daemon, or a DOCKER_HOST set in the environment.

`make build` now calls the new `script/build`, which stamps the version
and commit as the Makefile did. A VERSION set in the environment now
wins over `git describe`, not only one given as `make build VERSION=x`.
build, clean, install and docker-run are phony; install depends on
build. The vet target is removed: `script/test` runs `go vet` first.

Model: opus-5-5
2026-10-04 10:42:09 +02:00
clawbot 71c386ecbf Stop vault safety checks from reading unreadable state as empty (closes #51)
check / check (push) Waiting to run
Adding a PGP unlocker checked unlockers.d for a duplicate and, when the
directory or an unlocker's metadata file could not be read, reported no
duplicate and went on. The check now reads unlockers.d itself and stops
with an error naming the path and cause; `unlocker list` keeps skipping
unlockers it cannot read.

The same flaw guarded removing the last unlocker and removing a vault
(an unreadable secrets directory counted as no secrets) and vault
import (an unreadable pub.age counted as no long-term key). Those now
stop with an error too.

`vault rm` and `unlocker rm` keep the state directory lock and now do
their work in an unexported function, as `vault import` does.

Model: opus-5-5
2026-10-04 10:15:19 +02:00
clawbot cba526d33f Ignore secrets and editor files in .gitignore (closes #40)
check / check (push) Waiting to run
.gitignore had no secret patterns at all. It is now the org's standard
file, which ignores .env, .env.*, *.pem and *.key and editor and OS
files, plus this repo's /secret (anchored, so internal/secret/ is not
matched), *.log, *.test and settings.local.json. The stale
.cursorrules and coverage.out entries are gone. No tracked file
matches the new patterns.

.dockerignore also leaves out node_modules and ends with a newline.
.git stays in the build context because the build stamps the version
with git describe; .git/config stays excluded.

Model: opus-5-5
2026-10-04 09:24:59 +02:00
clawbot fb4481b4f7 Refuse to create a vault that already exists (closes #74)
check / check (push) Waiting to run
vault.CreateVault now checks for the vault before writing anything and
fails with "vault NAME already exists" (vault.ErrVaultExists). secret
init and secret vault create call it while holding the state directory
lock, so two creates at once cannot both pass the check. Before, either
command over an existing vault replaced its metadata, passphrase
unlocker and longterm.age, so none of its secrets could be decrypted.

Both commands now ask for the unlocker passphrase before creating the
vault, so one stopped at that prompt leaves no vault without an
unlocker behind, which they would then refuse to create again.

The lock tests set up the vault "work" instead of "default", which init
now refuses to create again.

Model: opus-5-5
2026-10-04 08:25:40 +02:00
clawbot 5ec59862ff Speed up the internal/cli lock and two-vault tests (closes #80)
check / check (push) Waiting to run
The test that each changing command waits for the state directory lock
slept a fixed 100 ms per command. It now polls the goroutine stacks until
the command is parked in vault.LockStateDir, checks the state directory
is unchanged, and releases the lock; a command that takes no lock still
fails by finishing first.

newTwoVaultFs creates its two vaults, each with a passphrase unlocker,
once, and returns a fresh copy of them on every call, so the six path and
move tests no longer each pay for two passphrase key derivations.

Model: opus-5-5
2026-10-04 08:08:04 +02:00
clawbot e640d10964 Reject secret mv onto the same secret under another name (closes #78)
check / check (push) Waiting to run
On a case-insensitive filesystem (the macOS default) "Foo" and "foo" name
one secret, so `secret mv --force Foo foo` removed the destination, which
was the source, and lost the secret with every version. Between vaults the
copy replaced the source, and removing the source then removed the copy.

Both kinds of move now compare the two secret directories with
os.SameFile before changing anything and reject the move if they are one,
with or without --force. The tests give one secret two names with
symbolic links on the real filesystem.

Model: opus-5-5
2026-10-04 07:42:11 +02:00
clawbot 4e562f834f Run golangci-lint only in docker, on every run (closes #55)
check / check (push) Waiting to run
script/lint builds the new Dockerfile.lint, where golangci-lint runs as
a build step. The lint stage is rebuilt on every run, so an unchanged
tree is linted too; the module download stays cached. script/bootstrap
no longer installs golangci-lint. The Dockerfile lint stage calls
golangci-lint directly, since make lint now starts a docker build.
golangci-lint config verify is not run: it fetches its schema live over
unpinned HTTPS.

Model: opus-5-5
2026-10-04 07:07:52 +02:00
clawbot 641d5659ec Keep unlocker list working when unlocker metadata is corrupt (closes #42)
check / check (push) Waiting to run
PGPUnlocker.GetID() panicked when its metadata could not be read or
parsed, which took down `secret unlocker list` for every unlocker. It
now warns with the unlocker's directory and returns `pgp-unknown`;
metadata with an empty GPG key ID counts as corrupt too.
ListUnlockers now skips, with a warning, an unlocker whose metadata
file cannot be checked for, read or parsed, as it already did for a
missing one. The listing's ID lookup skips such a directory without
warning again.

This is the first half of the issue only. Passing the mnemonic in
memory moved to #60.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-04 06:42:14 +02:00
clawbot 663986f551 Stop secret mv deleting a secret moved onto itself (closes #73)
check / check (push) Successful in 1m43s
`secret mv --force x x` deleted the secret: a move within one vault
removes an existing destination before renaming the source onto it. The
same happened for `work:x work:`, `work:x work` and `work:x ""`, and for
`work:x work/:x`, which named one vault two ways and so was taken for a
move between vaults.

A move whose two names are the same is now rejected before anything
changes. Every vault named with `vault:` must be one of the existing
vaults by exact name, checked before choosing between the two kinds of
move. A move within a named vault no longer makes it the current vault.

The test runs each rejected move on a copy of two in-memory vaults and
requires the exact error and an unchanged state directory.

Model: opus-5-5
2026-10-04 05:08:06 +02:00
clawbot 32a61ff963 Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 1m31s
Each command that changes the state directory holds one lock: flock(2)
on `lock` in the state directory, dropped by the kernel if the process
dies, or a process-wide mutex on the in-memory test filesystem. It
covers the state directory, not each vault, because `currentvault`,
`vault create` and cross-vault moves span vaults, and a lock file in a
vault would be deleted by `vault remove` under a waiting command.

Files go through `secret.WriteFileAtomic`; versions, new secrets and
cross-vault copies are built in a temporary directory and renamed into
place; removals rename out of the way first. Left for later: replacing
an unlocker (#71) and deleting
what an interrupted command leaves under a `.tmp-` name
(#75).

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-04 04:57:58 +02:00
clawbot bdb1c7ec18 Accept a version argument only if the secret has that version (closes #67)
check / check (push) Successful in 1m30s
version rm, version promote and get --version joined the version
argument into a path unchecked, so "", ".", "..", "../../.." removed or
read every version, the secret, the vault or directories above it.

A version is now accepted only if it is one of the versions
ListVersions lists for the secret, compared by name before any path is
built (secret.VersionExists, used by all three). An empty --version is
rejected instead of meaning the current version: GetSecretVersion no
longer treats "" as current, and GetSecret looks the current version
up itself.

Model: opus-5-5
2026-10-04 03:25:00 +02:00
clawbot 51e3731076 Wipe memguard buffers on every exit and restore echo (closes #35)
check / check (push) Successful in 1m35s
Entry() now returns the exit code and only main calls os.Exit, so the
deferred memguard.Purge() in Entry() runs on success and on error;
before, os.Exit(1) skipped every deferred Destroy().

SIGINT and SIGTERM go through memguard's handler, which wipes every
buffer and exits with status 1. The passphrase prompt turns terminal
echo off until its read returns, and the handler exits before that, so
the handler first restores the terminal settings saved at startup, but
only when this process is in the terminal's foreground process group: a
background process that changes the terminal is stopped instead of
exiting.

Model: opus-5-5
2026-10-04 02:07:58 +02:00
clawbot a5faec0466 Reject invalid secret names before any command builds a path (closes #33)
check / check (push) Successful in 1m13s
`secret rm ..` deleted the whole vault; `secret rm .` and `secret rm ""`
deleted every secret. rm, mv, the version commands, encrypt and decrypt
built paths from the name unchecked; import checked it only after
reading the source file.

Each now calls vault.ValidateSecretName, which wraps the existing rule,
on the name as given, before building any path; MoveSecret checks both
names before switching the current vault. Its error and README.md state
the rule. The test-only copy of the rule in internal/secret is removed.

The regression test runs each rejected command on a copy of two
in-memory vaults and requires the exact error and an unchanged state
directory.

Model: opus-5-5
2026-10-04 01:29:28 +02:00
clawbot 7c6531eaf7 Keep the keychain unlocker passphrase in locked memory (closes #36)
check / check (push) Successful in 39s
The passphrase protecting the keychain unlocker's age key was a plain
string passed through encoding/json, leaving copies in ordinary memory
when an unlocker was created and each time one was used.

It is now generated into a locked buffer, and KeychainData, moved to
keychaindata.go, which is not darwin-only so its tests run on Linux,
writes and reads the keychain JSON itself: encode copies the parts
straight into a locked buffer, and decodeKeychainData takes the
passphrase from a json.RawMessage that it wipes. The JSON field names
are unchanged. keychainunlocker.go only calls this code and stores the
item from the locked buffer without a string copy.

Model: opus-5-5
2026-10-03 17:07:56 +02:00
clawbot d52b4f1240 Let a plain docker build pass and stamp the git version (closes #57)
check / check (push) Successful in 1m1s
The size tests skip a case whose secret needs more locked memory than
the process can lock, found by locking a buffer of that size: memguard
panics otherwise, and a plain `docker build .` runs under an 8 MiB
RLIMIT_MEMLOCK. script/cibuild, or any process allowed to lock past the
limit, runs every case.

The build stage stamps the VERSION build argument, else
`git describe --tags --always`, and fails when .git is present but
yields no version. `make build` stamps `git describe` too instead of
the fixed 0.1.0. .dockerignore keeps .git/config out; script/docker is
now the canonical copy.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 14:16:02 +02:00
73 changed files with 6175 additions and 1215 deletions
+9
View File
@@ -1,3 +1,9 @@
# .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
secret
coverage.out
@@ -10,6 +16,9 @@ coverage.out
*.swo
*~
# Dependencies
node_modules
# macOS
.DS_Store
+29 -7
View File
@@ -1,12 +1,34 @@
# OS
.DS_Store
**/.DS_Store
Thumbs.db
# 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
*.log
cli.test
vault.test
*.test
settings.local.json
# Stale files
.cursorrules
coverage.out
+24 -2
View File
@@ -6,10 +6,16 @@ WORKDIR /src
COPY go.mod go.sum ./
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 . .
RUN make fmt-check
RUN make lint
# Not make lint: script/lint is a docker build, which cannot run in here.
RUN golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10)
@@ -24,10 +30,26 @@ WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
# As in the lint stage: the RUN steps below run again on each script/cibuild.
ARG CHECK_EPOCH
COPY . .
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
# alpine 3.23 (2026-03-10)
+19
View File
@@ -0,0 +1,19 @@
# Lint image, built by script/lint: 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 ./...
+6 -16
View File
@@ -1,13 +1,7 @@
export CGO_ENABLED=1
export DOCKER_HOST := ssh://root@ber1app1.local
# Version information
VERSION := 0.1.0
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
docker-run clean install hooks
default: check
@@ -17,13 +11,9 @@ bootstrap:
setup:
@script/setup
build: ./secret
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
vet:
go vet ./...
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
build:
@script/build
test:
@script/test
@@ -49,7 +39,7 @@ docker-run:
clean:
rm -f ./secret
install: ./secret
install: build
cp ./secret $(HOME)/bin/secret
fmt-check:
+35 -6
View File
@@ -91,6 +91,9 @@ Lists all available vaults. The current vault is marked.
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>`
Switches to the specified vault for subsequent operations.
@@ -113,7 +116,9 @@ automatically switch to another vault if removing the current one.
Adds a secret to the current vault. Reads the secret value from stdin.
- `--force, -f`: Overwrite existing secret
**Secret Name Format:** `[a-z0-9\.\-\_\/]+`
**Secret Name Format:** 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.
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
- Examples: `database/password`, `api.key`, `ssh_private_key`
@@ -137,6 +142,9 @@ matching.
Moves or renames a secret within the current vault.
- 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
### Version Management
@@ -193,7 +201,9 @@ Creates a new unlocker of the specified type:
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
does not ask for confirmation. Cannot remove the last unlocker if the vault
has secrets unless --force is used.
has secrets unless --force is used. 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`: Force removal of last unlocker even if vault has secrets
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE
@@ -308,6 +318,18 @@ 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_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
### Encryption
@@ -494,15 +516,21 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
module download), idempotently
- `script/bootstrap` — install all dependencies (Go, Go module
download), idempotently; golangci-lint is not installed, it runs in
docker
- `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by
other scripts such as `script/docker`
- `script/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
failure)
- `script/lint` — run `golangci-lint`
- `script/lint` — run `golangci-lint` in docker only: builds
`Dockerfile.lint`, where the linter is a build step that runs on every
call, also on an unchanged tree
- `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`, and
@@ -510,7 +538,8 @@ them. We provide:
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
checks)
checks), with a new `CHECK_EPOCH` build argument on every run so the
checks run again on an unchanged tree
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
then `script/check`
- `script/install-precommit` — install the git pre-commit hook that
+207 -16
View File
@@ -25,6 +25,207 @@ Bring the repo into policy compliance in one commit:
# Completed Steps
- 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: `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. One added under the directory name of an
existing unlocker is still written into that directory in place
(https://git.eeqj.de/sneak/secret/issues/71).
- 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:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- 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
@@ -80,24 +281,16 @@ Bring the repo into policy compliance in one commit:
- Command injection: GPG key IDs passed unescaped to exec.Command
(pgpunlocker.go:323-327); data.String() passed unescaped to the
security command (keychainunlocker.go:472-476).
- Memory security: KeychainData stores AgePrivKeyPassphrase as a
plain string (keychainunlocker.go:342,393-396); age identity
.String() creates unprotected copies (keychainunlocker.go:356,
pgpunlocker.go:256, version.go:155); age secret key held in a
plain string in cli/crypto.go:86,91,113; private keys exposed via
buffer.Bytes() to GPGEncryptFunc and EncryptWithPassphrase.
- Race conditions: no file locking in vault/secrets.go:142-176;
non-atomic writes can leave the vault inconsistent.
- Input validation: dots in secret names risk path traversal
(vault/secrets.go:75-99); no maximum secret size (DoS).
- Memory security: age identity .String() creates unprotected
copies (keychainunlocker.go:356, pgpunlocker.go:256,
version.go:155); age secret key held in a plain string in
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
to GPGEncryptFunc and EncryptWithPassphrase.
- Input validation: no maximum secret size (DoS).
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100).
- High priority:
- Return errors instead of panicking on corrupted metadata
(pgpunlocker.go:116, keychainunlocker.go:141).
- Secure temporary file handling and cleanup.
- Print cobra usage only for argument errors, not internal
failures.
- Initialize a default unlock key at vault creation.
- Confirmation prompts for destructive operations (keys rm, vault
deletion).
@@ -108,8 +301,6 @@ Bring the repo into policy compliance in one commit:
suggestions.
- Validate GPG key existence before creating PGP unlock keys.
- Split oversized CLI functions.
- Document env var security (SB_UNLOCK_PASSPHRASE,
SB_SECRET_MNEMONIC); clear after use.
- mlock/munlock for sensitive allocations.
- Cleanups: read statedir from environment or default instead of
passing it around.
+6 -2
View File
@@ -1,8 +1,12 @@
// Package main is the entry point for the secret CLI application.
package main
import "git.eeqj.de/sneak/secret/internal/cli"
import (
"os"
"git.eeqj.de/sneak/secret/internal/cli"
)
func main() {
cli.Entry()
os.Exit(cli.Entry())
}
+1 -1
View File
@@ -16,6 +16,7 @@ require (
github.com/stretchr/testify v1.8.4
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
)
@@ -31,7 +32,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+45 -3
View File
@@ -3,8 +3,10 @@ package cli
import (
"fmt"
"os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@@ -14,6 +16,11 @@ type Instance struct {
fs afero.Fs
stateDir string
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
}
// NewCLIInstance creates a new CLI instance with the real filesystem
@@ -69,7 +76,42 @@ func (cli *Instance) GetStateDir() string {
return cli.stateDir
}
// Print outputs to the command's configured output writer
func (cli *Instance) Print(a ...any) (int, error) {
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
// either calls it once, before anything else, and passes the buffers on
// 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))
}
+7 -1
View File
@@ -123,7 +123,9 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
}
// completeVaultQualifiedSecrets completes "vault:secret" references once a
// colon is present in the input
// colon is present in the input. It completes nothing when the vault part
// is not a valid vault name, so that a name such as ".." cannot list a
// directory outside vaults.d.
func completeVaultQualifiedSecrets(
fs afero.Fs, stateDir, toComplete string,
) []string {
@@ -134,6 +136,10 @@ func completeVaultQualifiedSecrets(
vaultName := parts[0]
secretPrefix := parts[1]
if vault.ValidateVaultName(vaultName) != nil {
return nil
}
vlt := vault.NewVault(fs, stateDir, vaultName)
secrets, err := vlt.ListSecrets()
+41
View File
@@ -0,0 +1,41 @@
//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)
}
}
+195
View File
@@ -0,0 +1,195 @@
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))
})
}
}
+56 -16
View File
@@ -41,6 +41,9 @@ func newCryptoCmd(
cli.cmd = cmd
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return run(cli, args[0], inputFile, outputFile)
},
}
@@ -70,22 +73,19 @@ func newDecryptCmd() *cobra.Command {
)
}
// resolveEncryptionKey returns a secure buffer holding the age secret key
// for the named secret, generating and storing a new key if the secret
// does not exist. The caller must destroy the returned buffer.
func (cli *Instance) resolveEncryptionKey(
// storeNewEncryptionKey generates an age secret key and stores it as the
// 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) {
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
exists, err := secretObj.Exists()
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
return nil, err
}
defer release()
if !exists {
// Secret doesn't exist, generate new age key and store it
identity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age key: %w", err)
@@ -102,6 +102,32 @@ func (cli *Instance) resolveEncryptionKey(
}
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
@@ -122,12 +148,19 @@ func (cli *Instance) resolveEncryptionKey(
// Encrypt encrypts data using an age secret key stored in a secret
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get or create the age secret key for this secret
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
if err != nil {
@@ -191,12 +224,19 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
// Decrypt decrypts data using an age secret key stored in a secret
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
@@ -275,13 +315,13 @@ func isValidAgeSecretKey(key string) bool {
return err == nil
}
// getSecretValue retrieves the value of a secret using the appropriate
// unlocker
// getSecretValue retrieves the value of a secret with the vault's mnemonic
// when it has one, else with the current unlocker
func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) {
if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil)
if vlt.Mnemonic != nil {
return secretObj.GetValue(nil, vlt.Mnemonic)
}
unlocker, err := vlt.GetCurrentUnlocker()
@@ -289,5 +329,5 @@ func (cli *Instance) getSecretValue(
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker)
return secretObj.GetValue(unlocker, nil)
}
+108
View File
@@ -0,0 +1,108 @@
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)
}
+11
View File
@@ -76,6 +76,9 @@ func newGenerateSecretCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
},
}
@@ -155,12 +158,20 @@ func (cli *Instance) GenerateSecret(
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
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Protect the generated secret immediately
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
defer secretBuffer.Destroy()
+38 -23
View File
@@ -39,16 +39,20 @@ func RunInit(cmd *cobra.Command, _ []string) error {
log.Fatalf("failed to initialize CLI: %v", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd)
}
// promptMnemonic reads the mnemonic from the environment or interactively.
// The returned cleanup function must be deferred by the caller.
func promptMnemonic() (string, func(), error) {
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// 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 envMnemonic, func() {}, nil
return cli.Mnemonic, func() {}, nil
}
secret.Debug("Prompting user for mnemonic phrase")
@@ -58,23 +62,23 @@ func promptMnemonic() (string, func(), error) {
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
}
fmt.Fprintln(os.Stderr) // Add newline after hidden input
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// setupDefaultVault creates the default vault and derives its long-term
// identity from the mnemonic
func (cli *Instance) setupDefaultVault(
stateDir, mnemonicStr string,
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")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
@@ -92,7 +96,7 @@ func (cli *Instance) setupDefaultVault(
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key", "error", err)
@@ -103,8 +107,21 @@ func (cli *Instance) setupDefaultVault(
return vlt, ltIdentity, nil
}
// Init initializes the secret manager
// Init initializes the secret manager, holding the state directory lock
// while initialize runs
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")
// Create state directory
@@ -123,12 +140,13 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
}
// Prompt for mnemonic
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
secret.Debug("Empty mnemonic provided")
@@ -147,12 +165,16 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
errInvalidMnemonicPhrase)
}
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer cleanupPassphrase()
// Create the default vault and derive its long-term key
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
if err != nil {
return err
}
@@ -162,13 +184,6 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Prompt for passphrase for unlocker
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
+22 -7
View File
@@ -286,7 +286,7 @@ func TestSecretManagerIntegration(t *testing.T) {
// Test 25: Concurrent operations
// Purpose: Test multiple simultaneous operations
// Expected: Proper locking/synchronization, no corruption
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv)
test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
// Test 26: Large secret values
// Purpose: Test with large secret values (e.g., certificates)
@@ -829,6 +829,14 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
require.NoError(t, err, "get current version should succeed")
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
// An empty --version is not a version; it does not mean the current one
output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "", "database/password")
require.Error(t, err, "get with an empty version should fail")
assert.Contains(t, output, "version '' not found", "should reject the empty version")
}
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@@ -2001,28 +2009,35 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
}
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
t.Helper()
// Make sure we're in default vault
_, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed")
// Run multiple concurrent reads
// Run multiple concurrent reads, as separate processes: within one
// process the first command to read the mnemonic would unset it for
// the others
const numReaders = 5
errCh := make(chan error, numReaders)
for i := range numReaders {
go func(id int) {
output, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic,
}, "get", "database/password")
cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.Output()
switch {
case err != nil:
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
case strings.TrimSpace(output) == "":
case strings.TrimSpace(string(output)) == "":
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
default:
errCh <- nil
+564
View File
@@ -0,0 +1,564 @@
//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
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
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", false, func(cli *Instance, _, _ string) error {
return cli.RemoveSecret(cli.cmd, "test/secret", false)
}},
{"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", false, func(cli *Instance, olderVersion, _ string) error {
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion)
}},
{"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", false, func(cli *Instance, _, _ string) error {
return cli.RemoveVault(cli.cmd, "other", false)
}},
{"unlocker add", false, func(cli *Instance, _, _ string) error {
return cli.UnlockersAdd("passphrase", cli.cmd)
}},
{"unlocker rm", 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)
}
+235
View File
@@ -0,0 +1,235 @@
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)
}
+437
View File
@@ -0,0 +1,437 @@
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.
// 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+"/unlockers.d/passphrase/")
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 ..", "..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", false)
}},
{"rm .", ".", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", false)
}},
{`rm ""`, "", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", false)
}},
{"rm ../../etc", "../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", false)
}},
{"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 ..", "..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion)
}},
{"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 x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version)
}},
{"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`
// 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])
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))
}
+50 -8
View File
@@ -4,17 +4,38 @@ import (
"os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
// Entry is the entry point for the secret CLI application
func Entry() {
cmd := newRootCmd()
// Entry runs the secret CLI and returns the process exit code. It wipes
// every memguard buffer before it returns, so the caller must do nothing
// but exit with the code.
func Entry() int {
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
// 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)
err := cmd.Execute()
if err != nil {
os.Exit(1)
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 {
@@ -25,9 +46,30 @@ func newRootCmd() *cobra.Command {
Short: "A simple secrets manager",
Long: `A simple secrets manager to store and retrieve sensitive ` +
`information securely.`,
// Ensure usage is shown after errors
SilenceUsage: false,
// Cobra prints the error a command returns; Entry does not.
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")
+254 -91
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"slices"
"strings"
@@ -40,6 +41,7 @@ var (
errVaultDoesNotExist = errors.New("does not exist")
errCrossVaultSourceUnqualified = errors.New(
"source must specify vault (e.g., vault:secret) for cross-vault move")
errMoveOntoItself = errors.New("cannot be moved onto itself")
)
// bufferInfo tracks a protected buffer and the number of bytes used in it
@@ -79,6 +81,9 @@ func newAddCmd() *cobra.Command {
cli.cmd = cmd // Set the command for stdin access
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
secret.Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force)
@@ -109,6 +114,15 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
// Without --version, get the current version. A given
// --version is checked as typed, so an empty one is rejected.
if !cmd.Flags().Changed("version") {
return cli.GetSecret(cmd, args[0])
}
return cli.GetSecretWithVersion(cmd, args[0], version)
},
}
@@ -166,6 +180,9 @@ func newImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ImportSecret(cmd, args[0], sourceFile, force)
},
}
@@ -240,6 +257,9 @@ The source secret is deleted after successful copy.`,
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.MoveSecret(cmd, args[0], args[1], force)
},
}
@@ -346,6 +366,8 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
secret.Debug("Got current vault", "vault_name", vlt.GetName())
// Read secret value directly into protected buffers
@@ -377,6 +399,15 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
valueBuffer := combineBuffers(buffers, totalSize)
defer valueBuffer.Destroy()
// Locked only now that stdin has been read: in `secret encrypt key |
// secret add name`, holding the lock while reading would leave each
// command waiting for the other.
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Add the secret to the vault
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
"value_length", valueBuffer.Size(), "force", force)
@@ -393,21 +424,38 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return nil
}
// GetSecret retrieves and prints a secret from the current vault
// GetSecret retrieves and prints the current version of a secret
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
return cli.GetSecretWithVersion(cmd, secretName, "")
secret.Debug("GetSecret called", "secretName", secretName)
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
value, err := vlt.GetSecret(secretName)
if err != nil {
return err
}
defer value.Destroy()
// Write the value straight from locked memory, with no trailing newline
_, _ = cmd.OutOrStdout().Write(value.Bytes())
return nil
}
// GetSecretWithVersion retrieves and prints a specific version of a secret
// GetSecretWithVersion retrieves and prints a specific version of a secret.
// The version must be one of the secret's versions.
func (cli *Instance) GetSecretWithVersion(
cmd *cobra.Command, secretName string, version string,
) error {
secret.Debug("GetSecretWithVersion called",
"secretName", secretName, "version", version)
// Store the command for output
cli.cmd = cmd
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -416,35 +464,24 @@ func (cli *Instance) GetSecretWithVersion(
return err
}
// Get the secret value
var value []byte
if version == "" {
value, err = vlt.GetSecret(secretName)
} else {
value, err = vlt.GetSecretVersion(secretName, version)
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get the secret value
value, err := vlt.GetSecretVersion(secretName, version)
if err != nil {
secret.Debug("Failed to get secret", "error", err)
return err
}
defer value.Destroy()
secret.Debug("Got secret value", "valueLength", len(value))
secret.Debug("Got secret value", "valueLength", value.Size())
// Print the secret value to stdout
_, _ = cli.Print(string(value))
// Write the value straight from locked memory, with no trailing newline
_, _ = cmd.OutOrStdout().Write(value.Bytes())
secret.Debug("Printed value to stdout")
// Debug: Log what we're actually printing
secret.Debug("Secret retrieval debug info",
"secretName", secretName,
"version", version,
"valueLength", len(value),
"valueAsString", string(value),
"isEmpty", len(value) == 0)
return nil
}
@@ -603,12 +640,19 @@ func printSecretsTable(
func (cli *Instance) ImportSecret(
cmd *cobra.Command, secretName, sourceFile string, force bool,
) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Read secret value from the source file into protected buffers
file, err := cli.fs.Open(sourceFile)
if err != nil {
@@ -635,6 +679,14 @@ func (cli *Instance) ImportSecret(
valueBuffer := combineBuffers(buffers, totalSize)
defer valueBuffer.Destroy()
// Locked only now that the file has been read, as in AddSecret: the
// file may be a pipe written by another secret command.
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Store the secret in the vault
err = vlt.AddSecret(secretName, valueBuffer, force)
if err != nil {
@@ -649,6 +701,17 @@ func (cli *Instance) ImportSecret(
// RemoveSecret removes a secret from the vault
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) 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
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -683,7 +746,7 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
}
// Remove the secret directory
err = cli.fs.RemoveAll(secretDir)
err = secret.RemoveDirAtomic(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to remove secret: %w", err)
}
@@ -694,21 +757,31 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
return nil
}
// MoveSecret moves or renames a secret (within or across vaults)
// MoveSecret moves or renames a secret (within or across vaults), holding
// the state directory lock.
func (cli *Instance) MoveSecret(
cmd *cobra.Command, source, dest string, force bool,
) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.moveSecret(cmd, source, dest, force)
}
// moveSecret does the work of MoveSecret. The caller holds the state
// directory lock.
func (cli *Instance) moveSecret(
cmd *cobra.Command, source, dest string, force bool,
) error {
// Parse source and destination
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
// If neither is qualified, this is a simple within-vault rename
if !srcQualified && !destQualified {
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
}
// Cross-vault move requires source to be qualified
if !srcQualified {
if !srcQualified && destQualified {
return errCrossVaultSourceUnqualified
}
@@ -716,53 +789,103 @@ func (cli *Instance) MoveSecret(
// Format: "work:secret default" means move to vault "default"
// Format: "work:secret default:newname" means move to vault "default"
// with a new name
if !destQualified {
if srcQualified && !destQualified {
// Check if dest is actually a vault name
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err == nil && slices.Contains(vaults, dest) {
_, err := cli.existingVault(dest)
if err == nil {
// dest is a vault name, use source secret name
destVaultName = dest
destSecretName = srcSecretName
}
// If destVaultName is still empty, dest is a secret name in source vault
if destVaultName == "" {
} else {
// dest is a secret name in source vault
destVaultName = srcVaultName
destSecretName = dest
}
}
// If destination secret name is empty, use source secret name
if destSecretName == "" {
// If destination secret name is empty, use source secret name. A plain
// rename keeps it empty, so that the check below rejects it.
if srcQualified && destSecretName == "" {
destSecretName = srcSecretName
}
// Same vault? Use simple rename if possible (optimization)
if srcVaultName == destVaultName {
// Select the vault and do a simple move
err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
if err != nil {
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
}
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
}
// Cross-vault move
return cli.moveSecretCrossVault(
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
}
// moveSecretWithinVault handles rename within the current vault
func (cli *Instance) moveSecretWithinVault(
cmd *cobra.Command, source, dest string, force bool,
) error {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
// Check both names, for every form of the move, before building any path
// from them.
err := vault.ValidateSecretName(srcSecretName)
if err != nil {
return err
}
vaultDir, err := currentVlt.GetDirectory()
err = vault.ValidateSecretName(destSecretName)
if err != nil {
return err
}
// Neither name is qualified: a rename within the current vault.
if !srcQualified {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
return cli.moveSecretWithinVault(
cmd, vlt, srcSecretName, destSecretName, force)
}
// Both vault names must be valid and name existing vaults exactly, so
// that two spellings of one vault, such as "work" and "work/", are never
// taken for two vaults. A named vault does not become the current vault.
srcVault, err := cli.existingVault(srcVaultName)
if err != nil {
return err
}
destVault, err := cli.existingVault(destVaultName)
if err != nil {
return err
}
if srcVaultName == destVaultName {
return cli.moveSecretWithinVault(
cmd, srcVault, srcSecretName, destSecretName, force)
}
return cli.moveSecretCrossVault(
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
}
// existingVault returns the vault with the given name, or an error if the
// name is not a valid vault name or there is no such vault. Unlike
// vault.SelectVault, it leaves the current vault as it is.
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
err := vault.ValidateVaultName(name)
if err != nil {
return nil, err
}
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return nil, fmt.Errorf("failed to list vaults: %w", err)
}
if !slices.Contains(vaults, name) {
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
return vault.NewVault(cli.fs, cli.stateDir, name), nil
}
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
// MoveSecret, has already checked both secret names.
func (cli *Instance) moveSecretWithinVault(
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
) error {
// With --force the destination is removed before the source is renamed
// onto it, which would delete the secret.
if source == dest {
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return err
}
@@ -782,6 +905,18 @@ func (cli *Instance) moveSecretWithinVault(
destEncoded := strings.ReplaceAll(dest, "/", "%")
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
// Removing a destination that is the source under another name, such as
// "foo" for "Foo" on a case-insensitive filesystem, would delete it too.
same, err := cli.sameDirectory(sourceDir, destDir)
if err != nil {
return err
}
if same {
return fmt.Errorf("secret '%s' %w: '%s' is the same secret on "+
"this filesystem", source, errMoveOntoItself, dest)
}
exists, err = afero.DirExists(cli.fs, destDir)
if err != nil {
return fmt.Errorf("failed to check if destination secret exists: %w", err)
@@ -792,7 +927,7 @@ func (cli *Instance) moveSecretWithinVault(
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
}
err = cli.fs.RemoveAll(destDir)
err = secret.RemoveDirAtomic(cli.fs, destDir)
if err != nil {
return fmt.Errorf("failed to remove existing destination: %w", err)
}
@@ -808,56 +943,84 @@ func (cli *Instance) moveSecretWithinVault(
return nil
}
// moveSecretCrossVault handles moving between different vaults
// sameDirectory reports whether the existing directory dir and the path
// other are one directory under two names, as secrets.d/Foo and
// secrets.d/foo are on a case-insensitive filesystem, or a directory and a
// symbolic link to it. Removing other to make room for dir would then delete
// dir. It is false if other does not exist, and always false on the
// in-memory filesystem, which has no such aliasing and whose files
// os.SameFile does not compare.
func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
dirInfo, err := cli.fs.Stat(dir)
if err != nil {
return false, fmt.Errorf("failed to check %s: %w", dir, err)
}
otherInfo, err := cli.fs.Stat(other)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check %s: %w", other, err)
}
return os.SameFile(dirInfo, otherInfo), nil
}
// moveSecretCrossVault handles moving between two different vaults. Its
// caller, MoveSecret, has already checked both secret names and that both
// vaults exist.
func (cli *Instance) moveSecretCrossVault(
cmd *cobra.Command,
srcVaultName, srcSecretName,
destVaultName, destSecretName string,
srcVault *vault.Vault, srcSecretName string,
destVault *vault.Vault, destSecretName string,
force bool,
) error {
// Get source vault
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
// Verify source vault exists
exists, err := afero.DirExists(cli.fs, srcVaultDir)
if err != nil || !exists {
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
}
// Verify source secret exists
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
exists, err = afero.DirExists(cli.fs, srcSecretDir)
exists, err := afero.DirExists(cli.fs, srcSecretDir)
if err != nil || !exists {
return fmt.Errorf("secret '%s' %w in vault '%s'",
srcSecretName, errSecretNotFound, srcVaultName)
srcSecretName, errSecretNotFound, srcVault.Name)
}
// Get destination vault
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
// The source is removed after the copy, so a destination that is the
// source under another name would be lost with it.
destVaultDir, err := destVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Verify destination vault exists
exists, err = afero.DirExists(cli.fs, destVaultDir)
if err != nil || !exists {
return fmt.Errorf("destination vault '%s' %w",
destVaultName, errVaultDoesNotExist)
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
if err != nil {
return err
}
if same {
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
destVault.Name, destSecretName)
}
// Copying needs the long-term keys of both vaults
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
_, err = destVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
}
// Count versions for user feedback
@@ -872,18 +1035,18 @@ func (cli *Instance) moveSecretCrossVault(
}
// Delete source secret
err = cli.fs.RemoveAll(srcSecretDir)
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
if err != nil {
// Copy succeeded but delete failed - warn but don't fail
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
return nil
}
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
return nil
}
+68 -14
View File
@@ -10,18 +10,57 @@ import (
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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.
//
@@ -31,11 +70,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs()
// Set test mnemonic
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
require.NoError(t, err)
// Set current vault
@@ -59,6 +95,7 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
// verifies the outcome.
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
@@ -102,7 +139,10 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, testData, retrievedValue,
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original (without newline)")
}
@@ -110,6 +150,7 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
// verifies the outcome.
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
@@ -151,12 +192,16 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// 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")
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
}
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
@@ -216,7 +261,7 @@ func TestAddSecretVariousSizes(t *testing.T) {
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
@@ -276,7 +321,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth
sizes := []int{
@@ -300,6 +345,8 @@ func TestAddSecretBufferGrowth(t *testing.T) {
for _, size := range sizes {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
// Create test data of exactly the specified size
@@ -331,16 +378,19 @@ func TestAddSecretBufferGrowth(t *testing.T) {
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, testData, retrievedValue,
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original exactly")
})
}
}
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
//
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
func TestAddSecretStreamingBehavior(t *testing.T) {
t.Parallel()
fs, vlt := newSizeTestVault(t)
// Create a custom reader that simulates slow streaming input
@@ -372,7 +422,11 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret("streaming-test")
require.NoError(t, err)
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
}
// slowReader simulates a reader that returns data in small chunks
+148 -48
View File
@@ -16,7 +16,6 @@ import (
"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"
)
@@ -49,7 +48,6 @@ var (
"is already added as an unlocker")
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
errLastUnlocker = errors.New("refusing to remove last unlocker")
errUnlockerExists = errors.New("unlocker already exists")
)
// UnlockerInfo represents unlocker information for display
@@ -231,6 +229,9 @@ func newUnlockerAddCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
unlockerType := args[0]
// Validate unlocker type
@@ -349,6 +350,10 @@ func unlockerIDFromDir(
// itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID.
//
// A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first,
// which has already warned about that directory.
func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
@@ -371,9 +376,6 @@ func findUnlockerIDByMetadata(
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
secret.Warn("Could not read unlocker metadata file",
"path", metadataPath, "error", err)
continue
}
@@ -381,9 +383,6 @@ func findUnlockerIDByMetadata(
err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil {
secret.Warn("Could not parse unlocker metadata file",
"path", metadataPath, "error", err)
continue
}
@@ -534,6 +533,12 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
// UnlockersAdd adds a new unlocker
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
switch unlockerType {
case unlockerTypePassphrase:
return cli.addPassphraseUnlocker(cmd)
@@ -577,19 +582,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the
// long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if passphrase is set in environment variable
var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else {
// The new unlocker gets the passphrase from the environment, which also
// unlocks the current passphrase unlocker, else the one entered here
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err)
}
}
defer passphraseBuffer.Destroy()
}
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
@@ -610,7 +615,8 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return errKeychainMacOSOnly
}
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
}
@@ -640,7 +646,8 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
return errSecureEnclaveMacOSOnly
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
}
@@ -682,7 +689,8 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Resolve the GPG key ID to its fingerprint
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
// and the new unlocker's metadata both use this result
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
@@ -691,12 +699,20 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
// Check if this GPG key is already added
expectedID := "pgp-" + fingerprint
err = cli.checkUnlockerExists(vlt, expectedID)
exists, err := cli.checkUnlockerExists(vlt, expectedID)
if err != nil {
return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w",
gpgKeyID, err,
)
}
if exists {
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
}
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return err
}
@@ -710,9 +726,23 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return nil
}
// UnlockersRemove removes an unlocker with safety checks
// UnlockersRemove removes an unlocker, holding the state directory lock
// while removeUnlocker runs
func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command,
) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.removeUnlocker(unlockerID, force, cmd)
}
// removeUnlocker removes an unlocker with safety checks
func (cli *Instance) removeUnlocker(
unlockerID string, force bool, cmd *cobra.Command,
) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@@ -720,14 +750,43 @@ func (cli *Instance) UnlockersRemove(
return err
}
// Get list of unlockers
// Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed.
unlockers, err := vlt.ListUnlockers()
if err != nil {
return fmt.Errorf("failed to list unlockers: %w", err)
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Check if we're removing the last unlocker
removingLast := false
if len(unlockers) == 1 {
lastID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, unlockers[0], true)
if err != nil {
return err
}
removingLast = lastID == unlockerID
}
// unlockerID may instead name a directory left out of the list. If its
// metadata file is missing or corrupt it is not a working unlocker, so
// removing it never removes the last one. If the file cannot be checked
// for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
removingLast = true
}
if removingLast {
// Check if vault has secrets
numSecrets, err := vlt.NumSecrets()
if err != nil {
@@ -761,8 +820,28 @@ func (cli *Instance) UnlockersRemove(
return nil
}
// metadataUnreadable reports whether checking for or reading the metadata
// file in the unlocker directory unlockerDir fails. A missing file is not
// a failure.
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
exists, err := afero.Exists(fs, metadataPath)
if err == nil && exists {
_, err = afero.ReadFile(fs, metadataPath)
}
return err != nil
}
// UnlockerSelect selects an unlocker as current
func (cli *Instance) UnlockerSelect(unlockerID string) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -772,44 +851,65 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID)
}
// checkUnlockerExists checks if an unlocker with the given ID exists
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
// Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers()
if err != nil {
secret.Warn("Could not list unlockers during duplicate check", "error", err)
return nil // If we can't list unlockers, assume it doesn't exist
}
// Get vault directory to construct unlocker instances
// checkUnlockerExists reports whether the vault already has an unlocker
// with the given ID. It returns an error, and no answer, when unlockers.d
// or an unlocker's metadata file cannot be read; the caller must then not
// create the unlocker. It reads unlockers.d itself because
// vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over.
func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, unlockerID string,
) (bool, error) {
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory during duplicate check",
"error", err)
return nil
return false, fmt.Errorf("failed to get vault directory: %w", err)
}
// Check each unlocker's ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
for _, metadata := range unlockers {
// Construct the unlocker matching this metadata to get its ID
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
if err != nil {
secret.Warn(
"Could not read unlockers directory during duplicate check, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
entries, err := afero.ReadDir(cli.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if id != "" && id == unlockerID {
return errUnlockerExists
unlockerDir := filepath.Join(unlockersDir, entry.Name())
metadataBytes, err := afero.ReadFile(
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return false, fmt.Errorf(
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
)
}
var metadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
continue
}
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
return true, nil
}
}
return nil
return false, nil
}
+105
View File
@@ -0,0 +1,105 @@
//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)
}
+167
View File
@@ -0,0 +1,167 @@
// 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 is
// removed by its directory name only as the last unlocker is.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"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
// can be removed, unless the vault holds secrets: the corrupt unlocker
// cannot unlock the vault, so the second is its last. The corrupt one can
// be removed by its directory name without --force even then.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel()
tests := []struct {
name string
unlockerID string
withSecret bool
wantErr error
wantEntries []string
}{
{
name: "the other unlocker",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
wantEntries: []string{listTestUnlockerDirOne},
},
{
name: "the other unlocker, the last one, with secrets",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
withSecret: true,
wantErr: errLastUnlocker,
wantEntries: []string{
listTestUnlockerDirOne, listTestUnlockerDirTwo,
},
},
{
name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne,
withSecret: true,
wantEntries: []string{listTestUnlockerDirTwo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
if tt.withSecret {
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
}
instance, cmd := newTestInstance(fs)
err := instance.UnlockersRemove(tt.unlockerID, false, cmd)
require.ErrorIs(t, err, tt.wantErr)
assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName),
listTestUnlockersDirName),
tt.wantEntries...)
})
}
}
// TestUnlockerRemoveWithUnreadableMetadata asserts that removing the only
// unlocker of a vault with secrets by its directory name, when its
// metadata file cannot be checked for or read, is refused without --force:
// listing leaves it out, but it may still be the vault's only working
// unlocker. With --force it is removed. The state directory lock refuses
// the failing filesystem, so the test calls removeUnlocker, which
// UnlockersRemove runs once it holds the lock.
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))
err := instance.removeUnlocker(listTestUnlockerDirOne, false, cmd)
require.ErrorIs(t, err, errLastUnlocker)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, true, cmd))
assertDirEntries(t, base, unlockersDir)
})
}
}
+151 -2
View File
@@ -1,13 +1,19 @@
// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when the unlockers.d directory
// cannot be read while the listing is being rendered:
// 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
@@ -22,6 +28,7 @@ import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
@@ -92,6 +99,49 @@ func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
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(
@@ -227,3 +277,102 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
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")
})
}
}
+362
View File
@@ -0,0 +1,362 @@
// 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?), 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 removeVault
// for RemoveVault.
//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, cmd := newTestInstance(&statFailFs{Fs: base, path: path})
err := instance.removeUnlocker(
"pgp-"+listTestGPGKeyID+"A", false, cmd)
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, cmd := newTestInstance(tt.failFs(base))
err := instance.removeVault(cmd, unreadableTestOtherVault, false)
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")
})
}
}
// 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)
}
+46
View File
@@ -0,0 +1,46 @@
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)
}
}
+104 -73
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
@@ -85,6 +84,9 @@ func newVaultCreateCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.CreateVault(cmd, args[0])
},
}
@@ -136,6 +138,9 @@ func newVaultImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.VaultImport(cmd, vaultName)
},
}
@@ -228,28 +233,14 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return nil
}
// setMnemonicEnv sets the mnemonic environment variable and returns a
// function that restores the previous value
func setMnemonicEnv(mnemonicStr string) func() {
originalMnemonic := os.Getenv(secret.EnvMnemonic)
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
return func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}
}
// resolvePassphrase returns the unlock passphrase from the environment or
// prompts the user for it with confirmation
func resolvePassphrase() (*memguard.LockedBuffer, error) {
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
// 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 memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
return cli.UnlockPassphrase, func() {}, nil
}
secret.Debug("Prompting user for unlock passphrase")
@@ -257,40 +248,29 @@ func resolvePassphrase() (*memguard.LockedBuffer, error) {
// Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return nil, fmt.Errorf("failed to read passphrase: %w", err)
return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
}
return passphraseBuffer, nil
return passphraseBuffer, passphraseBuffer.Destroy, nil
}
// CreateVault creates a new vault
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
// Get or prompt for mnemonic
var mnemonicStr string
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
secret.Debug("Using mnemonic from environment variable")
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase(
"Enter your BIP39 mnemonic phrase: ")
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
return err
}
defer mnemonicBuffer.Destroy()
defer release()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
return errMnemonicEmpty
}
@@ -303,12 +283,16 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
return errInvalidMnemonicPhrase
}
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer cleanupPassphrase()
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
if err != nil {
return err
}
@@ -330,13 +314,6 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Get or prompt for passphrase
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
@@ -354,7 +331,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// SelectVault selects a vault as the current one
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
err := vault.SelectVault(cli.fs, cli.stateDir, name)
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
err = vault.SelectVault(cli.fs, cli.stateDir, name)
if err != nil {
return err
}
@@ -388,18 +371,23 @@ func (cli *Instance) vaultImportPreflight(
// Check if vault already has a public key
pubKeyPath := vaultDir + "/pub.age"
_, err = cli.fs.Stat(pubKeyPath)
if err == nil {
exists, err = afero.Exists(cli.fs, pubKeyPath)
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
mnemonic := os.Getenv(secret.EnvMnemonic)
if mnemonic == "" {
if cli.Mnemonic == nil {
return "", "", "", errMnemonicEnvNotSet
}
mnemonic := cli.Mnemonic.String()
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonic)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
@@ -442,8 +430,26 @@ func updateVaultImportMetadata(
return nil
}
// VaultImport imports a mnemonic into a specific vault
// 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)
@@ -478,7 +484,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
secret.Debug("Storing long-term public key",
"pubkey", ltPublicKey, "vault_dir", vaultDir)
err = afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
if err != nil {
return fmt.Errorf("failed to store long-term public key: %w", err)
}
@@ -504,17 +510,13 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
}
// Get passphrase from environment variable
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
if passphraseStr == "" {
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
return errPassphraseEnvNotSet
}
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
vlt.Unlock(ltIdentity)
@@ -536,17 +538,26 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
}
// vaultHasSecrets reports whether the vault directory contains any secrets
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(cli.fs, secretsDir)
exists, err := afero.DirExists(cli.fs, secretsDir)
if err != nil {
return false, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err)
}
if !exists {
return false
return false, nil
}
entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil {
return false, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err)
}
return err == nil && len(entries) > 0
return len(entries) > 0, nil
}
// switchAwayFromVault selects another vault as current before removal
@@ -575,8 +586,25 @@ func (cli *Instance) switchAwayFromVault(
return nil
}
// RemoveVault removes a vault with safety checks
// RemoveVault removes a vault, holding the state directory lock while
// removeVault runs
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
err := vault.ValidateVaultName(name)
if err != nil {
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.removeVault(cmd, name, force)
}
// removeVault removes a vault with safety checks
func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) error {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
@@ -610,7 +638,10 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
}
// Check if vault has secrets
hasSecrets := cli.vaultHasSecrets(vaultDir)
hasSecrets, err := cli.vaultHasSecrets(vaultDir)
if err != nil {
return err
}
// Require --force if vault has secrets
if hasSecrets && !force {
@@ -626,7 +657,7 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
}
// Remove the vault directory
err = cli.fs.RemoveAll(vaultDir)
err = secret.RemoveDirAtomic(cli.fs, vaultDir)
if err != nil {
return fmt.Errorf("failed to remove vault directory: %w", err)
}
+37 -7
View File
@@ -54,6 +54,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ListVersions(cmd, args[0])
},
}
@@ -112,6 +115,11 @@ func VersionCommands(cli *Instance) *cobra.Command {
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
secret.Debug("ListVersions called", "secret_name", secretName)
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -167,6 +175,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
currentVersion = ""
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
@@ -239,6 +249,17 @@ func formatVersionTime(t *time.Time) string {
func (cli *Instance) PromoteVersion(
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
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -255,9 +276,7 @@ func (cli *Instance) PromoteVersion(
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if version exists
versionDir := filepath.Join(secretDir, "versions", version)
exists, err := afero.DirExists(cli.fs, versionDir)
exists, err := secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
@@ -282,6 +301,17 @@ func (cli *Instance) PromoteVersion(
func (cli *Instance) RemoveVersion(
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
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -308,9 +338,7 @@ func (cli *Instance) RemoveVersion(
}
// Check if version exists
versionDir := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(cli.fs, versionDir)
exists, err = secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
@@ -333,7 +361,9 @@ func (cli *Instance) RemoveVersion(
}
// Remove the version directory
err = cli.fs.RemoveAll(versionDir)
versionDir := filepath.Join(secretDir, "versions", version)
err = secret.RemoveDirAtomic(cli.fs, versionDir)
if err != nil {
return fmt.Errorf("failed to remove version: %w", err)
}
+87 -15
View File
@@ -7,6 +7,7 @@
// - TestPromoteVersionCommand: Tests `secret version promote` command
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestVersionCommandStructure: Tests command structure and help text
// - TestListVersionsEmptyOutput: Tests edge case with no versions
//
@@ -23,6 +24,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
@@ -43,6 +45,17 @@ const (
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()
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) {
@@ -59,11 +72,8 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default")
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -81,11 +91,13 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
require.NoError(t, err)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
@@ -94,6 +106,8 @@ func TestListVersionsCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -137,8 +151,9 @@ func TestListVersionsCommand(t *testing.T) {
assert.Equal(t, 2, versionLines)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -159,8 +174,9 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -172,6 +188,8 @@ func TestPromoteVersionCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -188,7 +206,10 @@ func TestPromoteVersionCommand(t *testing.T) {
// Current should be version-2
value, err := vlt.GetSecret("test/secret")
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
firstVersion := versions[1] // Older version
@@ -211,13 +232,17 @@ func TestPromoteVersionCommand(t *testing.T) {
assert.Contains(t, outputStr, firstVersion)
// Verify current is now version-1
value, err = vlt.GetSecret("test/secret")
promoted, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
defer promoted.Destroy()
assert.Equal(t, []byte("version-1"), promoted.Bytes())
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -244,11 +269,13 @@ func TestPromoteNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
@@ -257,6 +284,8 @@ func TestGetSecretWithVersion(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -276,8 +305,8 @@ func TestGetSecretWithVersion(t *testing.T) {
var buf bytes.Buffer
cmd.SetOut(&buf)
// Test getting current version (empty version string)
err = cli.GetSecretWithVersion(cmd, "test/secret", "")
// Test getting the current version
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, "version-2", buf.String())
@@ -290,6 +319,48 @@ func TestGetSecretWithVersion(t *testing.T) {
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) {
// Test that version commands are properly structured
@@ -313,8 +384,9 @@ func TestVersionCommandStructure(t *testing.T) {
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
+133
View File
@@ -0,0 +1,133 @@
package secret
import (
"errors"
"fmt"
"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 directory dir. When dir does
// not exist yet, write writes them 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
// renamed over one that has files in it, so when dir already exists, write
// writes into it in place; dir is then never removed.
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 write(dir)
}
// 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
}
+768
View File
@@ -0,0 +1,768 @@
package secret_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"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
}
// 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)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.DirExists(base, unlockerDir)
require.NoError(t, err)
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
"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
_, err = hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
})
}
}
// 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))
})
}
}
// TestWriteDirKeepsExistingDir makes writing into a directory that already
// exists fail, and checks that the directory, with what was in it, is still
// there: WriteDir writes into it in place and never removes it.
func TestWriteDirKeepsExistingDir(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", "passphrase")
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(got string) error {
assert.Equal(t, target, got)
return errInjected
})
require.ErrorIs(t, err, errInjected)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
})
}
}
+9 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@@ -32,6 +33,9 @@ func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
func (v *realVault) 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")
}
@@ -57,6 +61,8 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
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.
@@ -71,9 +77,10 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault)
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err)
defer result.Destroy()
-29
View File
@@ -1,29 +0,0 @@
//go:build darwin
package secret
import (
"crypto/rand"
"fmt"
"math/big"
)
// generateRandomString generates a random string of the specified length using the given character set
func generateRandomString(length int, charset string) (string, error) {
if length <= 0 {
return "", fmt.Errorf("length must be positive")
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}
+142
View File
@@ -0,0 +1,142 @@
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
}
+118
View File
@@ -0,0 +1,118 @@
//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)
}
+73 -76
View File
@@ -45,13 +45,6 @@ type KeychainUnlocker struct {
fs afero.Fs
}
// KeychainData represents the data stored in the macOS keychain
type KeychainData struct {
AgePublicKey string `json:"agePublicKey"`
AgePrivKeyPassphrase string `json:"agePrivKeyPassphrase"`
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
}
// GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity",
@@ -81,13 +74,18 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
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
var keychainData KeychainData
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
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)
}
defer keychainData.AgePrivKeyPassphrase.Destroy()
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
@@ -109,11 +107,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
// Create secure buffer for the keychain passphrase
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
@@ -195,7 +189,7 @@ func (k *KeychainUnlocker) Remove() error {
// Step 3: Remove directory
Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := k.fs.RemoveAll(k.Directory); err != nil {
if err := RemoveDirAtomic(k.fs, k.Directory); 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)
@@ -245,12 +239,14 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
}
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
// getLongTermPrivateKey derives the long-term private key from mnemonic when
// 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
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
// Check if mnemonic is available in environment variable
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
@@ -269,7 +265,7 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
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)
}
@@ -284,6 +280,10 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
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
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
@@ -328,8 +328,12 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
return ltPrivKeyBuffer, nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// 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
if err := checkMacOSAvailable(); err != nil {
return nil, err
@@ -347,16 +351,13 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
}
// Create unlocker directory using the keychain item name as the directory name
// The unlocker directory is named after the keychain item
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Step 1: Generate a new age keypair for the keychain unlocker
ageIdentity, err := age.GenerateX25519Identity()
@@ -364,79 +365,53 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
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
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
if err != nil {
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
}
defer agePrivKeyPassphrase.Destroy()
// Step 3: Store age recipient as plaintext
ageRecipient := ageIdentity.Recipient().String()
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
// Step 4: Encrypt age private key with the generated passphrase and store on disk
// Create secure buffers for both the private key and passphrase
// Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy()
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Step 5: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
// Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, err
}
defer ltPrivKeyData.Destroy()
// Step 6: Encrypt long-term private key to the new age unlocker
// Step 5: 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 7: Prepare keychain data
// Step 6: Prepare keychain data
keychainData := KeychainData{
AgePublicKey: ageRecipient,
AgePrivKeyPassphrase: agePrivKeyPassphrase,
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
}
keychainDataBytes, err := json.Marshal(keychainData)
keychainDataBuffer, err := keychainData.encode()
if err != nil {
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
}
// Create a secure buffer for keychain data
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 8: Store data in keychain
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
}
// Step 9: Create and write enhanced metadata
// Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: "keychain",
@@ -451,10 +426,37 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
if err := afero.WriteFile(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"),
metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
// Step 8: Write the unlocker's files and store the data in the keychain,
// the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error {
pubPath := filepath.Join(dir, "pub.txt")
if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
privPath := filepath.Join(dir, "priv.age")
if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
ltKeyPath := filepath.Join(dir, "longterm.age")
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return &KeychainUnlocker{
@@ -501,7 +503,7 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
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.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
@@ -576,8 +578,3 @@ func deleteFromKeychain(itemName string) error {
return nil
}
// generateRandomPassphrase generates a random passphrase for encrypting the age private key
func generateRandomPassphrase(length int) (string, error) {
return generateRandomString(length, "0123456789abcdef")
}
+3 -8
View File
@@ -76,13 +76,8 @@ func (k *KeychainUnlocker) Remove() error {
}
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(
_ afero.Fs, _ VaultInterface,
) (*memguard.LockedBuffer, error) {
func CreateKeychainUnlocker(
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
+25 -10
View File
@@ -19,6 +19,17 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// 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
}
// writeTestPublicKey writes the unlocker public key and verifies it exists.
func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir
}
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
//nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem
if os.Getenv("CI") == "true" {
@@ -195,14 +206,18 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
})
// Set test environment variable (cleaned up automatically)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
// Test getting identity from environment variable
t.Run("GetIdentityFromEnv", func(t *testing.T) {
unlocker.Passphrase = passphrase
// 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()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
// Verify the identity matches what we expect
@@ -213,20 +228,20 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
}
})
// Unset the environment variable to test interactive prompt
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
unlocker.Passphrase = nil
// Test getting identity from prompt (this would require mocking the
// prompt). For real integration tests, we'd need a way to mock the
// passphrase input. Here we just verify the error is what we expect
// when no passphrase is available.
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
_, err := unlocker.GetIdentity()
if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var")
t.Errorf("Should have failed to get identity without a passphrase")
}
})
+9 -19
View File
@@ -3,7 +3,6 @@ package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
@@ -127,7 +126,7 @@ func (p *PassphraseUnlocker) Remove() error {
// For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := p.fs.RemoveAll(p.Directory)
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
}
@@ -135,28 +134,19 @@ func (p *PassphraseUnlocker) Remove() error {
return nil
}
// getPassphrase retrieves the passphrase from memory, environment, or
// user input. Returns a LockedBuffer for secure memory handling
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
// the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
// Not NewBufferFromBytes, which would wipe p.Passphrase
passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, 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")
Debug("No passphrase in memory, prompting user")
// Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
+5 -3
View File
@@ -227,8 +227,10 @@ Passphrase: ` + testPassphrase + `
// Test data
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvGPGKeyID, keyID)
// Set up vault structure for testing
@@ -244,7 +246,7 @@ Passphrase: ` + testPassphrase + `
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -290,7 +292,7 @@ Passphrase: ` + testPassphrase + `
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
+118 -100
View File
@@ -155,14 +155,18 @@ func (p *PGPUnlocker) GetDirectory() string {
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 {
// Generate ID using GPG key ID: pgp-<keyid>
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
"directory", p.Directory, "error", err)
return "pgp-unknown"
}
return "pgp-" + gpgKeyID
@@ -172,7 +176,7 @@ func (p *PGPUnlocker) GetID() string {
func (p *PGPUnlocker) Remove() error {
// For PGP unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := p.fs.RemoveAll(p.Directory)
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
}
@@ -197,6 +201,10 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
if pgpMetadata.GPGKeyID == "" {
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
}
return pgpMetadata.GPGKeyID, nil
}
@@ -214,20 +222,13 @@ func generatePGPUnlockerName() (string, error) {
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
}
// preparePGPUnlockerDir checks GPG availability and creates the
// unlocker directory in the current vault, returning the vault and the
// directory path.
// pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the day.
//
//nolint:ireturn // the vault is only available behind VaultInterface
func preparePGPUnlockerDir(
func pgpUnlockerDir(
fs afero.Fs, stateDir string,
) (VaultInterface, string, error) {
// Check if GPG is available
err := checkGPGAvailable()
if err != nil {
return nil, "", err
}
// Get current vault
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
@@ -240,108 +241,53 @@ func preparePGPUnlockerDir(
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
}
// Create unlocker directory using the generated name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
err = fs.MkdirAll(unlockerDir, DirPerms)
if err != nil {
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
}
return vault, unlockerDir, nil
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
}
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
// 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 string, gpgKeyID string,
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) {
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir)
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
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
}
// Step 2: Store age recipient as plaintext
ageRecipient := ageIdentity.Recipient().String()
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
if err != nil {
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)
// Step 2: Encrypt the long-term private key to the new keypair, and the
// keypair's private key to the GPG key
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
vault, ageIdentity, gpgKeyID)
if err != nil {
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")
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
if 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")
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
if err != nil {
return nil, err
}
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
// the unlocker metadata file, returning the metadata written.
func writePGPUnlockerMetadata(
fs afero.Fs, unlockerDir string, gpgKeyID string,
) (*PGPUnlockerMetadata, error) {
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
@@ -357,14 +303,86 @@ func writePGPUnlockerMetadata(
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
err = afero.WriteFile(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"),
metadataBytes, FilePerms)
// Step 3: Write the unlocker's files, the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error {
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
})
if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
return nil, err
}
return &pgpMetadata, nil
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, 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 := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
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)
}
// 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, 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
+66
View File
@@ -0,0 +1,66 @@
package secret_test
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"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")))
}
+18 -11
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -35,6 +34,12 @@ type VaultInterface interface {
GetName() string
GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error)
GetOrDeriveLongTermKey() (*age.X25519Identity, 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)
}
@@ -76,9 +81,12 @@ func NewSecret(vault VaultInterface, name string) *Secret {
}
}
// GetValue retrieves and decrypts the current version's value using the
// provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// GetValue retrieves and decrypts the current version's value, with the
// vault's long-term key derived from mnemonic when it is not nil, else
// obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
@@ -113,9 +121,8 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// Create version object
version := NewVersion(s.vault, s.Name, currentVersion)
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
return s.getValueViaMnemonic(version, envMnemonic)
if mnemonic != nil {
return s.getValueViaMnemonic(version, mnemonic.String())
}
Debug("Using unlocker for vault access", "secret_name", s.Name)
@@ -209,11 +216,11 @@ func (s *Secret) Exists() (bool, error) {
}
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic in the environment and decrypts the version value with it.
// mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, envMnemonic string,
version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic from environment for direct long-term key derivation",
Debug("Using mnemonic for direct long-term key derivation",
"secret_name", s.Name)
// Get vault directory to read metadata
@@ -250,7 +257,7 @@ func (s *Secret) getValueViaMnemonic(
)
// Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
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)
+51 -78
View File
@@ -2,6 +2,7 @@
package secret
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -22,7 +23,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
var (
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock")
)
@@ -32,6 +33,7 @@ type MockVault struct {
fs afero.Fs
directory string
derivationIndex uint32
mnemonic *memguard.LockedBuffer
}
func (m *MockVault) GetDirectory() (string, error) {
@@ -61,12 +63,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index
mnemonic := os.Getenv(EnvMnemonic)
if mnemonic == "" {
if m.mnemonic == nil {
return errMnemonicNotSet
}
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil {
return err
}
@@ -107,6 +108,16 @@ 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) {
@@ -234,13 +245,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
}
}
//nolint:paralleltest // uses t.Setenv (process-global environment)
//nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption
t.Setenv(EnvMnemonic, testMnemonicValue)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
@@ -254,6 +265,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs,
directory: vaultDir,
derivationIndex: 0,
mnemonic: mnemonic,
}
// Test data
@@ -310,84 +322,45 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
})
}
// For testing purposes only
func isValidSecretName(name string) bool {
if name == "" {
return false
}
// Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
for _, char := range name {
if (char < 'a' || char > 'z') && // lowercase letters
(char < 'A' || char > 'Z') && // uppercase letters
(char < '0' || char > '9') && // numbers
char != '-' && // dash
char != '.' && // dot
char != '_' && // underscore
char != '/' { // slash
return false
}
}
return true
}
func TestSecretNameValidation(t *testing.T) {
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// GetValue, given the mnemonic, derives the long-term key at the derivation
// index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
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
fs := afero.NewMemMapFs()
vaultDir := "/test-config/vaults.d/test-vault"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
vlt := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 1,
mnemonic: mnemonic,
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
result := isValidSecretName(test.name)
if result != test.valid {
t.Errorf(
"isValidSecretName(%q) = %v, want %v",
test.name,
result,
test.valid,
)
}
})
}
}
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
// Set up test mnemonic
t.Setenv(EnvMnemonic, testMnemonicValue)
// Create temporary directory for vaults
fs := afero.NewOsFs()
tempDir, err := afero.TempDir(fs, "", "secret-test-")
err = afero.WriteFile(
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
require.NoError(t, err)
defer func() {
_ = fs.RemoveAll(tempDir)
}()
secretName, secretValue := "x", "value"
stateDir := filepath.Join(tempDir, ".secret")
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
err = vlt.AddSecret(secretName,
memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
// 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")
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, secretValue, value.String())
}
+35 -27
View File
@@ -148,7 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := s.fs.RemoveAll(s.Directory); err != nil {
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
@@ -207,9 +207,12 @@ func generateSEKeyLabel(vaultName string) (string, error) {
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used.
// The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil {
return nil, err
@@ -236,7 +239,7 @@ func CreateSecureEnclaveUnlocker(
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
@@ -254,7 +257,7 @@ func CreateSecureEnclaveUnlocker(
)
}
// Step 4: Create unlocker directory and write files
// Step 4: Prepare the unlocker directory's path and metadata
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
@@ -262,23 +265,7 @@ func CreateSecureEnclaveUnlocker(
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf(
"failed to create unlocker directory: %w",
err,
)
}
// Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
// Write metadata
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
@@ -294,9 +281,25 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write metadata: %w", err)
// Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error {
ltKeyPath := filepath.Join(dir, seLongtermFilename)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return &SecureEnclaveUnlocker{
@@ -306,14 +309,15 @@ func CreateSecureEnclaveUnlocker(
}, nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker.
// 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) {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
if mnemonic != nil {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
@@ -333,7 +337,7 @@ func getLongTermKeyForSE(
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
mnemonic.String(),
metadata.DerivationIndex,
)
@@ -352,6 +356,10 @@ func getLongTermKeyForSE(
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
+2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -80,6 +81,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
+56 -25
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@@ -131,7 +132,10 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value
// Save saves the version metadata and value. The files are written into a
// 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 {
if value == nil {
return errNilValueBuffer
@@ -145,14 +149,22 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
fs := sv.vault.GetFilesystem()
// Create version directory
err := fs.MkdirAll(sv.Directory, DirPerms)
// Create the versions directory the finished version is renamed into
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
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 version directory: %w", err)
return fmt.Errorf("failed to create versions directory: %w", err)
}
tmpDir, err := TempDirFor(fs, sv.Directory)
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)
@@ -173,21 +185,28 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
slog.String("public_key", versionIdentity.Recipient().String()),
)
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
if err != nil {
return err
}
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
if err != nil {
return err
}
err = sv.writeEncryptedMetadata(fs, versionIdentity)
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
if err != nil {
return err
}
err = fs.Rename(tmpDir, sv.Directory)
if err != nil {
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to move version into place: %w", err)
}
Debug("Successfully saved secret version",
"version", sv.Version, "secret_name", sv.SecretName)
@@ -358,17 +377,18 @@ func (sv *Version) GetValue(
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it.
// 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(sv.Directory, "pub.age")
pubKeyPath := filepath.Join(dir, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
@@ -385,10 +405,10 @@ func (sv *Version) writePublicKeyAndValue(
return fmt.Errorf("failed to encrypt version value: %w", err)
}
valuePath := filepath.Join(sv.Directory, "value.age")
valuePath := filepath.Join(dir, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
err = WriteFileAtomic(fs, valuePath, encryptedValue)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
@@ -399,9 +419,10 @@ func (sv *Version) writePublicKeyAndValue(
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it.
// 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()
@@ -435,10 +456,10 @@ func (sv *Version) writeEncryptedPrivateKey(
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
privKeyPath := filepath.Join(sv.Directory, "priv.age")
privKeyPath := filepath.Join(dir, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil {
Debug("Failed to write encrypted version private key",
"error", err, "path", privKeyPath)
@@ -450,9 +471,10 @@ func (sv *Version) writeEncryptedPrivateKey(
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it.
// 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)
@@ -476,10 +498,10 @@ func (sv *Version) writeEncryptedMetadata(
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
metadataPath := filepath.Join(dir, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
@@ -524,6 +546,18 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
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
// The file contains just the version name (e.g., "20231215.001")
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
@@ -540,15 +574,12 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
}
// SetCurrentVersion updates the "current" file to point to a specific version
// The file contains just the version name (e.g., "20231215.001")
// The file contains just the version name (e.g., "20231215.001"). It is
// replaced in one rename, so once written it always exists.
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
// Remove existing file if it exists
_ = fs.Remove(currentPath)
// Write just the version name to the file
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
err := WriteFileAtomic(fs, currentPath, []byte(version))
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}
+8
View File
@@ -87,6 +87,14 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {
+20 -8
View File
@@ -17,22 +17,28 @@ var (
"derived public key does not match vault: mnemonic may be incorrect",
)
// ErrInvalidVaultName indicates a vault name that does not match the
// allowed pattern [a-z0-9.\-_]+. Composed as
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
// ErrInvalidVaultName 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 does not match
// the allowed pattern [a-z0-9.\-_/]+. Composed as
// "invalid secret name '<name>': must match pattern [a-z0-9.\-_/]+",
// or as "invalid secret name: <name>" by GetSecretObject.
// ErrInvalidSecretName 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
@@ -48,7 +54,7 @@ var (
// ErrVersionNotFound indicates the requested secret version does not
// exist. Composed as
// "version <version> not found for secret <name>".
// "version '<version>' not found for secret '<name>'".
ErrVersionNotFound = errors.New("not found for secret")
// ErrNoVersions indicates the source secret has no versions. Composed
@@ -62,4 +68,10 @@ var (
// 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")
)
+29 -22
View File
@@ -1,13 +1,13 @@
package vault_test
import (
"bytes"
"os"
"path/filepath"
"slices"
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
@@ -40,46 +40,49 @@ func deriveVaultIdentity(
return ltIdentity
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestVaultWithRealFilesystem(t *testing.T) {
t.Parallel()
// Create a temporary directory for our tests
tempDir := t.TempDir()
// Use the real filesystem
fs := afero.NewOsFs()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Test currentvault file handling (plain file with relative path)
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)
})
}
@@ -95,7 +98,8 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
}
// Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -140,9 +144,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault now handles public key when
// mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -197,10 +202,11 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
if err != nil {
t.Fatalf("Failed to retrieve deep path secret: %v", err)
}
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) {
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
string(expectedValue), string(retrievedValue))
expectedValue, retrievedValue.Bytes())
}
}
@@ -214,9 +220,10 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault now handles public key when
// mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -317,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
}
@@ -333,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name)
@@ -354,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
// Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err)
}
@@ -402,14 +409,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create two vaults - CreateVault now handles public key when mnemonic
// is in env
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
// Create two vaults - CreateVault writes the public key derived from
// the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault1: %v", err)
}
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault2: %v", err)
}
+48 -23
View File
@@ -44,15 +44,12 @@ var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // the subtests are steps that build on each other
func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -119,7 +116,10 @@ func testCreateInitialVersion(
// Verify secret can be retrieved
value, err := vault.GetSecret(secretName)
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
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
@@ -166,7 +166,10 @@ func testCreateSecondVersion(
// Verify new value is current
value, err := vault.GetSecret(secretName)
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
versions, err = secret.ListVersions(fs, secretDir)
@@ -209,7 +212,10 @@ func testCreateThirdVersion(
// Current should be version-3
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-3-data"), value.Bytes())
}
func testRetrieveSpecificVersions(
@@ -225,20 +231,29 @@ func testRetrieveSpecificVersions(
// Get each version by its name
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value1)
defer value1.Destroy()
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
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
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value3)
// Empty version should return current
valueCurrent, err := vault.GetSecretVersion(secretName, "")
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), valueCurrent)
defer value3.Destroy()
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
// 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(
@@ -259,7 +274,10 @@ func testPromoteOldVersion(
// Verify current now returns the old version's value
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify the version metadata hasn't changed
// (promoting shouldn't modify timestamps)
@@ -330,9 +348,9 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
}
// TestVersionConcurrency tests concurrent version operations
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionConcurrency(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
@@ -345,6 +363,8 @@ func TestVersionConcurrency(t *testing.T) {
// Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) {
t.Parallel()
done := make(chan bool, 10)
errCh := make(chan error, 10)
@@ -353,8 +373,13 @@ func TestVersionConcurrency(t *testing.T) {
value, err := vault.GetSecret(secretName)
if err != nil {
errCh <- err
} else if string(value) != "initial" {
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
} else {
if value.String() != "initial" {
errCh <- fmt.Errorf("%w: %s",
errUnexpectedValue, value.Bytes())
}
value.Destroy()
}
done <- true
@@ -377,9 +402,9 @@ func TestVersionConcurrency(t *testing.T) {
}
// TestVersionCompatibility tests that old secrets without versions still work
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionCompatibility(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
+73
View File
@@ -0,0 +1,73 @@
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
}
+134
View File
@@ -0,0 +1,134 @@
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)
}
+65 -41
View File
@@ -3,7 +3,6 @@ package vault
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
@@ -11,6 +10,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -24,10 +24,12 @@ func init() {
})
}
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
// Note: We don't allow slashes in vault names unlike secret names
// isValidVaultName reports whether name is a valid vault name: only
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
// "..". With no path separator allowed, a vault is always one directory
// directly under vaults.d.
func isValidVaultName(name string) bool {
if name == "" {
if name == "" || name == "." || name == ".." {
return false
}
@@ -36,6 +38,21 @@ func isValidVaultName(name string) bool {
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
// The file contains just the vault name (e.g., "default")
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
@@ -138,19 +155,18 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
// It returns the derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (uint32, string, string, error) {
// Check if mnemonic is available in environment
mnemonic := os.Getenv(secret.EnvMnemonic)
if mnemonic == "" {
secret.Debug("No mnemonic in environment, vault created without long-term key",
if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
"vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil
}
secret.Debug("Mnemonic found in environment, deriving long-term key",
"vault", vaultName)
mnemonic := mnemonicBuffer.String()
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
@@ -169,7 +185,7 @@ func processMnemonicForVault(
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms)
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil {
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
}
@@ -191,28 +207,44 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// 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)
// Validate vault name
if !isValidVaultName(name) {
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
return nil, err
}
secret.Debug("Vault name validation passed", "vault_name", name)
// Create vault directory structure
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
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Create main vault directory
err := fs.MkdirAll(vaultDir, secret.DirPerms)
err = fs.MkdirAll(vaultDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create vault directory: %w", err)
}
@@ -235,7 +267,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name)
fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return nil, err
}
@@ -264,21 +296,21 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Create and return the vault
secret.Debug("Successfully created vault", "name", name)
return NewVault(fs, stateDir, name), nil
vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
}
// SelectVault selects the given vault as the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error {
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
// Validate vault name
if !isValidVaultName(name) {
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
return err
}
secret.Debug("Vault name validation passed", "vault_name", name)
@@ -295,21 +327,13 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
}
// Create or update the currentvault file with just the vault name
// Create or replace the currentvault file with just the vault name. It
// is replaced in one rename, so it never goes missing.
currentVaultPath := filepath.Join(stateDir, "currentvault")
// Remove existing file if it exists
_, err = fs.Stat(currentVaultPath)
if err == nil {
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
_ = fs.Remove(currentVaultPath)
}
// Write just the vault name to the file
secret.Debug("Writing currentvault file", "vault_name", name)
err = afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms)
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
if err != nil {
return fmt.Errorf("failed to select vault: %w", err)
}
+1 -1
View File
@@ -113,7 +113,7 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
return fmt.Errorf("failed to marshal vault metadata: %w", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
if err != nil {
return fmt.Errorf("failed to write vault metadata: %w", err)
}
+6 -10
View File
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
}
func TestWorkflowMismatch(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing
tempDir := t.TempDir()
fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command)
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
_, err := vault.CreateVault(fs, tempDir, "default")
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err)
}
@@ -321,19 +321,15 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
t.Setenv("SB_SECRET_MNEMONIC", "")
_, err = vault.CreateVault(fs, tempDir, "work")
_, err = vault.CreateVault(fs, tempDir, "work", nil)
if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err)
}
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
// Simulate the vault import process
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
// Get the next available derivation index for this mnemonic
// Simulate the vault import process: get the next available derivation
// index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get next derivation index: %v", err)
+13 -14
View File
@@ -3,7 +3,6 @@ package vault_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@@ -13,15 +12,13 @@ import (
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
@@ -41,6 +38,8 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretVersion(name, "")
require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name)
@@ -53,12 +52,12 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
@@ -68,15 +67,13 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
maliciousNames := []string{
@@ -87,6 +84,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name)
require.Contains(t, err.Error(), "invalid secret name")
+168 -136
View File
@@ -79,6 +79,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
// - No leading or trailing slashes
// - No double slashes
// - No names starting with dots
// - No ".." path segments
func isValidSecretName(name string) bool {
if name == "" {
return false
@@ -110,6 +111,22 @@ func isValidSecretName(name string) bool {
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
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil {
@@ -124,13 +141,11 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
)
// Validate secret name
if !isValidSecretName(name) {
err := ValidateSecretName(name)
if err != nil {
secret.Debug("Invalid secret name provided", "secret_name", name)
return fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
ErrInvalidSecretName, name,
)
return err
}
secret.Debug("Secret name validation passed", "secret_name", name)
@@ -156,17 +171,59 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
slog.String("secret_dir", secretDir),
)
// Check for an existing secret and prepare its directory
exists, previousVersion, err := v.prepareSecretDir(name, secretDir, force)
// Check for an existing secret and the version the new one supersedes
exists, previousVersion, err := v.checkExistingSecret(name, secretDir, force)
if err != nil {
return err
}
if exists {
return v.addVersion(name, secretDir, value, previousVersion)
}
return v.addNewSecret(name, secretDir, value)
}
// addNewSecret creates a secret by assembling its first version and current
// pointer in a temporary directory, then renaming that directory to
// secretDir, so an interrupted add leaves no half-made secret behind.
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.
defer func() { _ = v.fs.RemoveAll(buildDir) }()
err = v.addVersion(name, buildDir, value, nil)
if err != nil {
return err
}
err = v.fs.Rename(buildDir, secretDir)
if err != nil {
return fmt.Errorf("failed to move new secret into place: %w", err)
}
return nil
}
// addVersion saves value as a new version under secretDir, sets the
// notAfter timestamp of the version it supersedes, if any, and then points
// current at the new version. Until that last step, current still names the
// previous version, which stays readable.
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, exists)
name, secretDir, value, previousVersion, &now)
if err != nil {
return err
}
@@ -236,7 +293,7 @@ func updateVersionMetadata(
// Write encrypted metadata
metadataPath := filepath.Join(version.Directory, "metadata.age")
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms)
err = secret.WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
@@ -244,27 +301,44 @@ func updateVersionMetadata(
return nil
}
// GetSecret retrieves a secret from this vault
func (v *Vault) GetSecret(name string) ([]byte, error) {
// GetSecret retrieves the current version of a secret from this vault.
// The caller must destroy the returned buffer.
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
)
return v.GetSecretVersion(name, "")
// GetSecretObject validates the name and checks that the secret exists
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 (empty version
// means current)
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
// GetSecretVersion retrieves a specific version of a secret. The version
// must be one of the secret's versions; GetSecret gets the current one.
// 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",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
)
// Validate the name and resolve the version to fetch
version, err := v.resolveSecretVersion(name, version)
// Validate the name and check that the version exists
err := v.checkSecretVersion(name, version)
if err != nil {
return nil, err
}
@@ -302,26 +376,14 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
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",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(result)),
slog.Int("decrypted_length", decryptedValue.Size()),
)
// 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
return decryptedValue, nil
}
// UnlockVault unlocks the vault and returns the long-term private key
@@ -358,8 +420,9 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
if !isValidSecretName(name) {
return nil, fmt.Errorf("%w: %s", ErrInvalidSecretName, name)
err := ValidateSecretName(name)
if err != nil {
return nil, err
}
// First check if the secret exists by checking for the metadata file
@@ -394,12 +457,14 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
return secretObj, nil
}
// CopySecretVersion copies a single version from source to this vault
// It decrypts the value using srcIdentity and re-encrypts for this vault
// CopySecretVersion copies a single version from source into destSecretDir
// in this vault. It decrypts the value using srcIdentity and re-encrypts
// for this vault.
func (v *Vault) CopySecretVersion(
srcVersion *secret.Version,
srcIdentity *age.X25519Identity,
destSecretName string,
destSecretDir string,
destVersionName string,
) error {
secret.DebugWith("Copying secret version to vault",
@@ -425,6 +490,7 @@ func (v *Vault) CopySecretVersion(
// Create destination version with same name
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
destVersion.Directory = filepath.Join(destSecretDir, "versions", destVersionName)
// Copy metadata (preserve original timestamps)
destVersion.Metadata = srcVersion.Metadata
@@ -465,11 +531,11 @@ func (v *Vault) CopySecretAllVersions(
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Check if destination secret already exists and clear it if forced
// Refuse to replace an existing destination secret unless forced
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
err = v.prepareCopyDestination(destSecretDir, destSecretName, force)
err = v.checkCopyDestination(destSecretDir, destSecretName, force)
if err != nil {
return err
}
@@ -505,14 +571,8 @@ func (v *Vault) CopySecretAllVersions(
return fmt.Errorf("failed to get current version: %w", err)
}
// Create destination secret directory
err = v.fs.MkdirAll(destSecretDir, secret.DirPerms)
if err != nil {
return fmt.Errorf("failed to create destination secret directory: %w", err)
}
// Copy each version and set the current pointer, rolling back on error
err = v.copyVersionsWithRollback(srcVault, srcIdentity,
// Copy each version and the current pointer, then move the copy into place
err = v.copyVersions(srcVault, srcIdentity,
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
if err != nil {
return err
@@ -527,10 +587,10 @@ func (v *Vault) CopySecretAllVersions(
return nil
}
// prepareSecretDir checks for an existing secret directory and prepares it
// for a new version. It returns whether the secret already existed and the
// current version to be superseded, if any.
func (v *Vault) prepareSecretDir(
// 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
@@ -547,19 +607,6 @@ func (v *Vault) prepareSecretDir(
secret.Debug("Secret existence check complete", "exists", exists)
if !exists {
// Create secret directory for new secret
secret.Debug("Creating secret directory", "secret_dir", secretDir)
err = v.fs.MkdirAll(secretDir, secret.DirPerms)
if err != nil {
secret.Debug("Failed to create secret directory",
"error", err, "secret_dir", secretDir)
return false, nil, fmt.Errorf("failed to create secret directory: %w", err)
}
secret.Debug("Created secret directory successfully")
return false, nil, nil
}
@@ -624,17 +671,15 @@ func (v *Vault) updatePreviousVersion(
return nil
}
// resolveSecretVersion validates the secret name, verifies the secret and
// version exist, and resolves an empty version to the current one.
func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
// 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
if !isValidSecretName(name) {
err := ValidateSecretName(name)
if err != nil {
secret.Debug("Invalid secret name provided", "secret_name", name)
return "", fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
ErrInvalidSecretName, name,
)
return err
}
// Get vault directory
@@ -642,7 +687,7 @@ func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return "", err
return err
}
// Convert slashes to percent signs for storage
@@ -654,58 +699,38 @@ func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
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)
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
}
// Determine which version to get
if version == "" {
// Get current version
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return "", fmt.Errorf("failed to get current version: %w", err)
}
version = currentVersion
secret.Debug("Using current version", "version", version, "secret_name", name)
return fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
}
// Check if version exists
versionPath := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(v.fs, versionPath)
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)
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 fmt.Errorf("version '%s' %w '%s'", version, ErrVersionNotFound, name)
}
return version, nil
return nil
}
// createAndSaveVersion generates a new version name, sets the version
// timestamps, and saves the encrypted value. When saving fails for a newly
// created secret, the secret directory is removed again.
// 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, exists bool,
previousVersion *secret.Version, now *time.Time,
) (string, error) {
// Generate new version name
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
@@ -719,6 +744,7 @@ func (v *Vault) createAndSaveVersion(
// Create new version
newVersion := secret.NewVersion(v, name, versionName)
newVersion.Directory = filepath.Join(secretDir, "versions", versionName)
// Set version timestamps
if previousVersion == nil {
@@ -738,57 +764,73 @@ func (v *Vault) createAndSaveVersion(
if err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName)
// Clean up the secret directory if this was a new secret
if !exists {
secret.Debug("Cleaning up secret directory due to save failure",
"secret_dir", secretDir)
_ = v.fs.RemoveAll(secretDir)
}
return "", fmt.Errorf("failed to save version: %w", err)
}
return versionName, nil
}
// copyVersionsWithRollback copies each version of the source secret into the
// destination directory and sets the current version pointer, removing the
// partial copy when any step fails.
func (v *Vault) copyVersionsWithRollback(
// 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 {
// Copy each version
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, versionName)
err = v.CopySecretVersion(
srcVersion, srcIdentity, destSecretName, buildDir, versionName)
if err != nil {
// Rollback: remove partial copy
secret.Debug("Rolling back partial copy due to error", "error", err)
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
}
}
// Set current version
err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion)
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
if err != nil {
_ = v.fs.RemoveAll(destSecretDir)
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
}
// prepareCopyDestination ensures the destination secret directory can be
// created, removing an existing secret when force is set.
func (v *Vault) prepareCopyDestination(
// 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)
@@ -803,15 +845,5 @@ func (v *Vault) prepareCopyDestination(
)
}
if exists && force {
// Remove existing secret
secret.Debug("Removing existing destination secret", "path", destSecretDir)
err = v.fs.RemoveAll(destSecretDir)
if err != nil {
return fmt.Errorf("failed to remove existing destination secret: %w", err)
}
}
return nil
}
+36 -29
View File
@@ -41,14 +41,6 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon about"
// envTestMnemonic is the (deliberately different) mnemonic placed in the
// environment; the vault is unlocked manually with the derived key in
// createTestVaultWithKey.
//
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about"
// Shared fixtures for white-box tests in this package.
const (
testStateDir = "/test/state"
@@ -73,11 +65,8 @@ func addTestSecretToVault(
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -98,8 +87,9 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
return vault
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretCreatesVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -131,11 +121,15 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
// Get the secret value
retrievedValue, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
assert.Equal(t, expectedValue, retrievedValue)
defer retrievedValue.Destroy()
assert.Equal(t, expectedValue, retrievedValue.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretMultipleVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -165,11 +159,15 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
// Current value should be version-2
value, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetSecretVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -192,24 +190,31 @@ func TestVaultGetSecretVersion(t *testing.T) {
// Get specific version (first one)
firstVersion := versions[1] // Last in list is first created
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
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)
secondVersion := versions[0] // First in list is most recent
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
// Get current (empty version)
value, err = vault.GetSecretVersion(testSecretPath, "")
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer second.Destroy()
assert.Equal(t, []byte("version-2"), second.Bytes())
// An empty version is not one of the versions; GetSecret gets the
// current one
_, err = vault.GetSecretVersion(testSecretPath, "")
require.ErrorIs(t, err, ErrVersionNotFound)
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultVersionTimestamps(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -291,8 +296,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -307,8 +313,9 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestUpdateVersionMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
+123 -127
View File
@@ -70,7 +70,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.Debug("Creating passphrase unlocker instance",
"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":
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
@@ -126,7 +128,11 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
}
// 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
// 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(
@@ -137,42 +143,24 @@ func (v *Vault) findUnlockerByID(
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
}
skippedDirPath := ""
for _, file := range files {
if !file.IsDir() {
continue
}
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
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)
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if !ok {
if file.Name() == unlockerID {
skippedDirPath = unlockerDirPath
}
if !exists {
// Skip directories without metadata - they might not be unlockers
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
return nil, "", fmt.Errorf(
"failed to read metadata for unlocker %s: %w", file.Name(), err)
}
var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, "", fmt.Errorf(
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
// Create the appropriate unlocker instance
var tempUnlocker secret.Unlocker
@@ -195,7 +183,7 @@ func (v *Vault) findUnlockerByID(
}
}
return nil, "", nil
return nil, skippedDirPath, nil
}
// ListUnlockers returns a list of available unlockers for this vault
@@ -226,39 +214,12 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
var unlockers []UnlockerMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(),
"unlocker-metadata.json")
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())
if !file.IsDir() {
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
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf(
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok {
unlockers = append(unlockers, metadata)
}
}
@@ -266,7 +227,54 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return unlockers, nil
}
// RemoveUnlocker removes an unlocker from this vault
// readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
// 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
}
// 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 {
vaultDir, err := v.GetDirectory()
if err != nil {
@@ -277,15 +285,19 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil {
return err
}
if unlocker == nil {
if unlockerDir == "" {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
}
if unlocker == nil {
return secret.RemoveDirAtomic(v.fs, unlockerDir)
}
// Use the unlocker's Remove method
return unlocker.Remove()
}
@@ -301,39 +313,27 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil {
return err
}
if targetUnlockerDir == "" {
// A directory found without an unlocker is one ListUnlockers skips; it
// cannot be selected.
if unlocker == nil {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
}
// Create/update current-unlocker file with just the unlocker name
// Create or replace the current-unlocker file with just the unlocker
// name. It is replaced in one rename, so it never goes missing.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Remove existing file if it exists
exists, err := afero.Exists(v.fs, currentUnlockerPath)
if err != nil {
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
}
if exists {
err = v.fs.Remove(currentUnlockerPath)
if err != nil {
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
}
}
// Get just the unlocker name (basename of the directory)
unlockerName := filepath.Base(targetUnlockerDir)
// Write just the unlocker name to the file
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName),
secret.FilePerms)
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath, []byte(unlockerName))
if err != nil {
return fmt.Errorf("failed to create current-unlocker file: %w", err)
}
@@ -351,53 +351,23 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Create unlocker directory
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
// We need to get the long-term key (either from memory if unlocked, or
// derive it). Getting it before anything is written means failing to
// get it changes nothing, even when replacing the current unlocker.
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
}
// Write the unlocker keypair (public and passphrase-encrypted private)
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
if err != nil {
return nil, err
}
// Create metadata
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
Flags: []string{},
}
// Write metadata
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms)
if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
// Encrypt long-term private key to this unlocker
// We need to get the long-term key (either from memory if unlocked, or derive it)
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy()
@@ -407,11 +377,24 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
Flags: []string{},
}
err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
// Write the unlocker's files, the metadata last
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
encryptedLtPrivKey, metadataBytes)
})
if err != nil {
return nil, err
}
// Create the unlocker instance
@@ -457,19 +440,20 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
return metadata, nil
}
// writeUnlockerKeypair writes the unlocker's public key and its
// passphrase-encrypted private key into the unlocker directory.
func (v *Vault) writeUnlockerKeypair(
// 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 := afero.WriteFile(v.fs, pubKeyPath,
[]byte(unlockerIdentity.Recipient().String()),
secret.FilePerms)
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
[]byte(unlockerIdentity.Recipient().String()))
if err != nil {
return fmt.Errorf("failed to write unlocker public key: %w", err)
}
@@ -488,10 +472,22 @@ func (v *Vault) writeUnlockerKeypair(
// Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age")
err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
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
}
+32 -9
View File
@@ -3,12 +3,12 @@ package vault
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -18,6 +18,13 @@ type Vault struct {
fs afero.Fs
stateDir string
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
@@ -56,6 +63,18 @@ func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil
}
// SetMnemonic sets v.Mnemonic, for code that has v only as a
// 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) {
@@ -66,9 +85,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
if v.Mnemonic != nil {
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
}
// No mnemonic available, try to use current unlocker
@@ -138,7 +156,12 @@ func (v *Vault) NumSecrets() (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(v.fs, secretsDir)
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
}
@@ -162,7 +185,7 @@ func (v *Vault) NumSecrets() (int, error) {
exists, err := afero.Exists(v.fs, currentFile)
if err != nil {
continue // Skip directories we can't read
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
}
if exists {
@@ -176,9 +199,9 @@ func (v *Vault) NumSecrets() (int, error) {
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic(
envMnemonic string,
mnemonic string,
) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic from environment for long-term key derivation",
secret.Debug("Using mnemonic for long-term key derivation",
"vault_name", v.Name)
// Load vault metadata to get the derivation index
@@ -194,7 +217,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
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)
+6
View File
@@ -90,4 +90,10 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
exists, _ := afero.DirExists(fs, secretDir)
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())
}
+23 -12
View File
@@ -1,6 +1,7 @@
package vault_test
import (
"bytes"
"path/filepath"
"slices"
"testing"
@@ -26,12 +27,19 @@ const (
testPassphrase = "test-passphrase"
)
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) {
// Test environment will be cleaned up automatically by t.Setenv
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// 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) {
// Use in-memory filesystem
fs := afero.NewMemMapFs()
@@ -63,7 +71,8 @@ func TestVaultOperations(t *testing.T) {
func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -184,10 +193,11 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
if err != nil {
t.Fatalf("Failed to get secret: %v", err)
}
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) {
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Expected secret value '%s', got '%s'",
string(expectedValue), string(retrievedValue))
expectedValue, retrievedValue.Bytes())
}
}
@@ -219,6 +229,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
}
// Test vault unlocking (should happen automatically via mnemonic)
vlt.Mnemonic = testMnemonicBuffer(t)
if vlt.Locked() {
_, err := vlt.UnlockVault()
if err != nil {
@@ -279,15 +291,14 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
}
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
// Use in-memory filesystem
fs := afero.NewMemMapFs()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
+1 -6
View File
@@ -6,6 +6,7 @@
# make, node, yarn, go, or python). Node is used directly if installed;
# otherwise a pinned version is installed via nvm (installing nvm
# itself first, from a hash-verified release archive, never curl | sh).
# golangci-lint is never installed: script/lint runs it in docker.
#
# Uncomment the language sections in main() that apply to this repo.
set -eu
@@ -136,12 +137,6 @@ main() {
# ---- Go repos ----
if missing go; then pkg_install go golang go go; fi
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
# package: download a specific release archive from GitHub and
# verify its hash (verify_sha256), never curl | sh.
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
fi
go mod download
# ---- Python repos ----
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# script/build: build the `secret` binary into the repo root, with its
# version and git commit stamped in (`secret info` shows both).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
# A VERSION set in the environment wins (`make build VERSION=x`, as
# the Dockerfile does); otherwise `git describe` of this checkout.
version="${VERSION:-}"
if [ -z "$version" ]; then
version="$(git describe --tags --always --dirty 2>/dev/null ||
echo dev)"
fi
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
pkg=git.eeqj.de/sneak/secret/internal/cli
# Build the file, not the package `./cmd/secret`: a package build
# also stamps git status into the binary and fails where git cannot
# read the checkout, instead of falling back to `dev`/`unknown`.
go build -v \
-ldflags "-X '$pkg.Version=$version' -X '$pkg.GitCommit=$commit'" \
-o secret cmd/secret/main.go
}
main "$@"
+8 -3
View File
@@ -1,15 +1,20 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check
# (via make check), so a successful build implies all checks pass.
# The Gitea workflow runs this on push. The memlock ulimit is required
# because the test suite uses memguard, which mlocks memory.
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
# that lock large secrets in memory (memguard mlocks them) run; under the
# lower limit of a plain `docker build .` they are skipped.
# A cached build checks nothing: a new CHECK_EPOCH on every run makes the
# Dockerfile's check steps run again on an unchanged tree, while its base
# images and module downloads stay cached.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --ulimit memlock=-1:-1 .
docker build --ulimit memlock=-1:-1 \
--build-arg CHECK_EPOCH="$(date +%s)" .
}
main "$@"
+11 -2
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+14 -4
View File
@@ -1,14 +1,24 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter, in docker only. Builds Dockerfile.lint,
# where golangci-lint runs as a build step.
#
# A cached build lints nothing, so --no-cache-filter rebuilds the lint
# stage on every run, an unchanged tree included. It ignores a stage name
# that does not exist, so --target names the same stage: a rename then
# fails the build instead of serving the lint from cache. cacheonly keeps
# no image; only the build's success matters.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
golangci-lint run --timeout 5m
docker build \
--progress=plain \
--target lint \
--no-cache-filter=lint \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"