15 Commits
Author SHA1 Message Date
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) Waiting to run
`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) Waiting to run
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
46 changed files with 3111 additions and 523 deletions
+4 -1
View File
@@ -16,6 +16,9 @@ coverage.out
*.swo
*~
# Dependencies
node_modules
# macOS
.DS_Store
@@ -23,4 +26,4 @@ coverage.out
.claude/
# Local settings
.claude/settings.local.json
.claude/settings.local.json
+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
+2 -1
View File
@@ -9,7 +9,8 @@ RUN go mod download
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)
+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 := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
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:
+15 -4
View File
@@ -113,7 +113,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 +139,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
@@ -494,15 +499,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
+128 -21
View File
@@ -25,21 +25,132 @@ Bring the repo into policy compliance in one commit:
# Completed Steps
- 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); 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 an interrupted command leaves nothing
half-written, with one exception: an unlocker added under the
directory name of an existing one is rewritten in place, file by
file, and a crash part-way leaves it unable to open the vault. 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).
`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;
- from an unlocker add stopped before its metadata is written, a
directory that `unlocker list` warns about and `unlocker rm`
cannot remove;
- data under a `.tmp-` name in the state directory: a secret or
version 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
@@ -103,19 +214,15 @@ 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.
- 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.
+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())
}
-5
View File
@@ -68,8 +68,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
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...)
}
+153
View File
@@ -0,0 +1,153 @@
package cli_test
import (
"bytes"
"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 // t.Setenv forbids parallel subtests
func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// `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 := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
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(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
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.
t.Setenv(secret.EnvMnemonic, "")
for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).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)
}
}
// 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 // t.Setenv forbids parallel subtests
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Without the passphrase in the environment, both commands prompt for
// it, which fails because the tests do not run in a terminal.
t.Setenv(secret.EnvUnlockPassphrase, "")
// 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")
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)
err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir))
require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs))
})
}
}
+47 -22
View File
@@ -70,12 +70,11 @@ 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. It holds the
// state directory lock itself, so that Encrypt streams its input and output
// unlocked and cannot block a secret command at the other end of a pipe.
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) {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
@@ -84,6 +83,34 @@ func (cli *Instance) resolveEncryptionKey(
}
defer release()
identity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age key: %w", err)
}
// Store the generated key directly in a secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil {
secureBuffer.Destroy()
return nil, fmt.Errorf("failed to store age key: %w", err)
}
return secureBuffer, nil
}
// 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)
@@ -93,23 +120,11 @@ func (cli *Instance) resolveEncryptionKey(
}
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)
key, err := cli.storeNewEncryptionKey(vlt, secretName)
if !errors.Is(err, vault.ErrSecretExists) {
return key, err
}
// Store the generated key directly in a secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil {
secureBuffer.Destroy()
return nil, fmt.Errorf("failed to store age key: %w", err)
}
return secureBuffer, nil
// Another command stored the key since the check above: read it
}
// Secret exists, get the age secret key from it
@@ -130,6 +145,11 @@ 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 {
@@ -199,6 +219,11 @@ 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 {
+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)
}
+8 -7
View File
@@ -160,6 +160,14 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
errInvalidMnemonicPhrase)
}
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
@@ -175,13 +183,6 @@ func (cli *Instance) initialize(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")
+8
View File
@@ -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)) {
+339 -11
View File
@@ -2,8 +2,11 @@
package cli
import (
"bytes"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -12,12 +15,42 @@ 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"
"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.
func addAtOnce(
@@ -114,7 +147,8 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
value, err := vlt.GetSecretVersion("shared", version)
require.NoError(t, err)
values[string(value)] = true
values[string(value.Bytes())] = true
value.Destroy()
}
assert.Len(t, values, adds+1, "every add stored its own value")
@@ -148,7 +182,7 @@ func TestEncryptPipedIntoAdd(t *testing.T) {
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, "/plaintext", []byte("piped"), 0o600))
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
@@ -175,14 +209,14 @@ func TestEncryptPipedIntoAdd(t *testing.T) {
encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(pipeWriter)
err := encrypt.Encrypt("key", "/plaintext", "")
err := encrypt.Encrypt("key", testInput, "")
// Ends add's input, as the end of the pipe does
_ = pipeWriter.CloseWithError(err)
results <- err
}()
timeout := time.After(10 * time.Second)
timeout := time.After(lockWait)
for range commands {
select {
@@ -206,19 +240,313 @@ func TestFailedCommandReleasesLock(t *testing.T) {
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
require.Error(t, err)
taken := make(chan func(), 1)
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()
other, err := vault.CreateVault(fs, testStateDir, "other")
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")
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()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
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 // t.Setenv forbids parallel subtests
func TestChangingCommandsWaitForLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
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.
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
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.
func TestEncryptStreamsUnlocked(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
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() {
release, err := vault.LockStateDir(fs, testStateDir)
if assert.NoError(t, err) {
taken <- release
}
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(10 * time.Second):
t.Fatal("the failed command left the state directory locked")
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/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"
)
// 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.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
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, so ".." cannot reach the state
// directory itself.
{
"mv --force ..:x ..:y", "..:x", "..:y", true,
"vault '..' does not exist",
},
// Each of these spells "work" a second way. The spelling is not an
// existing 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 'work/' does not exist",
},
{
"mv --force work/:x work:", "work/:x", "work:", true,
"vault 'work/' does not exist",
},
{
"mv --force work:x ./work:x", workX, "./work:x", true,
"vault './work' does not exist",
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
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.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
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.
//
//nolint:paralleltest // t.Setenv
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
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) {
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")
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default")
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.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewOsFs()
stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default")
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)
}
+366
View File
@@ -0,0 +1,366 @@
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"
)
// 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()
t.Setenv(secret.EnvMnemonic, testMnemonic)
twoVaultsOnce.Do(func() {
fs := afero.NewMemMapFs()
for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name)
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 // newTwoVaultFs uses t.Setenv
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 // newTwoVaultFs uses t.Setenv
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) })
})
}
}
}
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
// with a version that is not the current one removes that version and
// changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
fs := newTwoVaultFs(t)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
// 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.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
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))
}
+26 -5
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()
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 {
os.Exit(1)
return 1
}
return 0
}
func newRootCmd() *cobra.Command {
+192 -88
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
@@ -109,6 +111,12 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
// 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)
},
}
@@ -402,21 +410,36 @@ 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
}
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 {
@@ -426,34 +449,21 @@ func (cli *Instance) GetSecretWithVersion(
}
// Get the secret value
var value []byte
if version == "" {
value, err = vlt.GetSecret(secretName)
} else {
value, err = vlt.GetSecretVersion(secretName, version)
}
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
}
@@ -612,6 +622,11 @@ 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 {
@@ -666,6 +681,11 @@ 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
@@ -717,7 +737,8 @@ 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 {
@@ -727,17 +748,20 @@ func (cli *Instance) MoveSecret(
}
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
}
@@ -745,53 +769,97 @@ 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 vaults must be existing vaults by exact name, 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 there
// is none. Unlike vault.SelectVault, it leaves the current vault as it is.
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
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
}
@@ -811,6 +879,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)
@@ -837,56 +917,80 @@ 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)
}
// 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
@@ -906,13 +1010,13 @@ func (cli *Instance) moveSecretCrossVault(
// 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
}
+18 -4
View File
@@ -143,7 +143,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)")
}
@@ -193,7 +196,11 @@ 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
@@ -375,7 +382,10 @@ 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")
})
}
@@ -416,7 +426,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
+69 -36
View File
@@ -49,7 +49,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
@@ -349,6 +348,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 +374,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 +381,6 @@ func findUnlockerIDByMetadata(
err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil {
secret.Warn("Could not parse unlocker metadata file",
"path", metadataPath, "error", err)
continue
}
@@ -697,8 +694,15 @@ 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)
}
@@ -716,7 +720,8 @@ 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 {
@@ -726,6 +731,13 @@ func (cli *Instance) UnlockersRemove(
}
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)
if err != nil {
@@ -790,44 +802,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
}
+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")
})
}
}
+353
View File
@@ -0,0 +1,353 @@
// 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, 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)
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)
}
+38 -15
View File
@@ -309,6 +309,14 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
return errInvalidMnemonicPhrase
}
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
@@ -336,13 +344,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")
@@ -400,8 +401,12 @@ 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)
}
@@ -561,17 +566,26 @@ func (cli *Instance) importMnemonic(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
@@ -600,7 +614,8 @@ 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 {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
@@ -608,6 +623,11 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
}
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 {
@@ -641,7 +661,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 {
+19 -6
View File
@@ -112,6 +112,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 {
@@ -239,6 +244,11 @@ 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
@@ -261,9 +271,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)
}
@@ -288,6 +296,11 @@ 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
@@ -320,9 +333,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)
}
@@ -345,6 +356,8 @@ func (cli *Instance) RemoveVersion(
}
// Remove the version directory
versionDir := filepath.Join(secretDir, "versions", version)
err = secret.RemoveDirAtomic(cli.fs, versionDir)
if err != nil {
return fmt.Errorf("failed to remove version: %w", err)
+53 -5
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"
@@ -188,7 +190,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,9 +216,12 @@ 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
@@ -276,8 +284,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 +298,46 @@ func TestGetSecretWithVersion(t *testing.T) {
assert.Equal(t, "version-1", buf.String())
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWritesBinaryValue(t *testing.T) {
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
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
+198 -26
View File
@@ -22,6 +22,7 @@ var errInjected = errors.New("injected failure")
const (
opCreate = "create"
opOpen = "open"
opSync = "sync"
opMkdir = "mkdir"
opRemove = "remove"
opRename = "rename"
@@ -31,15 +32,41 @@ const (
// version.
const currentFile = "current"
// unlockerMetadataFile is the file a new unlocker writes last.
const unlockerMetadataFile = "unlocker-metadata.json"
// 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, 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.
// 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
@@ -49,7 +76,12 @@ func (h hookFs) Create(name string) (afero.File, error) {
return nil, err
}
return h.Fs.Create(name)
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
@@ -61,7 +93,16 @@ func (h hookFs) OpenFile(
return nil, err
}
return h.Fs.OpenFile(name, flag, perm)
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 {
@@ -301,7 +342,10 @@ func TestLongestNames(t *testing.T) {
got, err := vlt.GetSecret(name)
require.NoError(t, err)
assert.Equal(t, "long", string(got))
defer got.Destroy()
assert.Equal(t, []byte("long"), got.Bytes())
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
@@ -339,7 +383,10 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
value, err := dest.GetSecret("shared")
require.NoError(t, err)
assert.Equal(t, "old", string(value))
defer value.Destroy()
assert.Equal(t, []byte("old"), value.Bytes())
})
}
}
@@ -472,10 +519,68 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
}
}
// TestSetCurrentVersionNeverMissing checks, before every change
// SetCurrentVersion makes, that the current file exists: a reader or a crash
// never finds the secret without a current version.
func TestSetCurrentVersionNeverMissing(t *testing.T) {
// 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.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
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 {
@@ -483,24 +588,91 @@ func TestSetCurrentVersionNeverMissing(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
secretDir := filepath.Join(dir, "secret")
require.NoError(t, base.MkdirAll(secretDir, 0o700))
require.NoError(t, secret.SetCurrentVersion(base, secretDir, "20231216.001"))
currentPath := filepath.Join(secretDir, currentFile)
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.Exists(base, currentPath)
require.NoError(t, err)
assert.True(t, exists, "current is missing")
var modes []os.FileMode
return nil
}}
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")
}
require.NoError(t, secret.SetCurrentVersion(fs, secretDir, "20231216.002"))
return nil
},
opened: func(_ string, perm os.FileMode) {
modes = append(modes, perm)
},
}
version, err := secret.GetCurrentVersion(base, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231216.002", version)
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) {
// No mnemonic, and no current unlocker to get the key from
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
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)
}
// TestPassphraseUnlockerWritesMetadataLast checks that the last file a new
// passphrase unlocker writes in its directory is its metadata: an unlocker
// directory without metadata is never used, so one interrupted earlier
// cannot be.
func TestPassphraseUnlockerWritesMetadataLast(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
var last string
fs := hookFs{Fs: base, before: func(_, path string) error {
if filepath.Dir(path) == unlockerDir {
last = filepath.Base(path)
}
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.Equal(t, unlockerMetadataFile, last)
}
-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)
}
+14 -30
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())
@@ -369,6 +363,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
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()
@@ -378,15 +373,12 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
// Step 4: Encrypt age private key with the generated passphrase and store on disk
// Create secure buffers for both the private key and 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)
}
@@ -422,13 +414,10 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
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
@@ -501,7 +490,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 +565,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")
}
+12 -4
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
@@ -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
}
-58
View File
@@ -310,64 +310,6 @@ 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) {
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
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
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
+13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@@ -545,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) {
+10 -5
View File
@@ -26,13 +26,18 @@ var (
// 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 +53,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
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test
import (
"bytes"
"os"
"path/filepath"
"slices"
@@ -197,10 +198,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())
}
}
+39 -13
View File
@@ -119,7 +119,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 +169,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 +215,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 +234,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 +277,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)
@@ -353,8 +374,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
+17 -3
View File
@@ -191,7 +191,11 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault
// CreateVault creates a new vault and selects it as the current vault. 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) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
@@ -207,12 +211,22 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
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)
}
+63 -64
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)
@@ -286,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
}
@@ -344,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
@@ -400,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
@@ -650,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
@@ -668,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
@@ -680,50 +699,30 @@ 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
+22 -10
View File
@@ -131,7 +131,10 @@ 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
@@ -165,7 +168,10 @@ 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
@@ -192,20 +198,26 @@ 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
+12 -7
View File
@@ -233,9 +233,10 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
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)
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
"directory", file.Name(), "error", err)
continue
}
if !exists {
@@ -247,16 +248,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
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)
secret.Warn("Skipping unlocker directory with unreadable metadata file",
"directory", file.Name(), "error", err)
continue
}
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)
secret.Warn("Skipping unlocker directory with corrupt metadata file",
"directory", file.Name(), "error", err)
continue
}
unlockers = append(unlockers, metadata)
+7 -2
View File
@@ -138,7 +138,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 +167,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 {
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test
import (
"bytes"
"path/filepath"
"slices"
"testing"
@@ -184,10 +185,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())
}
}
+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 "$@"
+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 "$@"