Compare commits
1
Commits
next
..
9f516af211
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f516af211 |
+1
-4
@@ -16,9 +16,6 @@ coverage.out
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
@@ -26,4 +23,4 @@ node_modules
|
|||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Local settings
|
# Local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
+7
-29
@@ -1,34 +1,12 @@
|
|||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
**/.DS_Store
|
||||||
|
|
||||||
# Editors
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
*.bak
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.sublime-*
|
|
||||||
|
|
||||||
# Agent scratch (worktrees of this repo, created and destroyed by
|
|
||||||
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
|
||||||
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
|
||||||
# entry and must not be given a `**/` prefix on the way into one.
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Environment / secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# This repo. /secret is the built binary, anchored so that it does not
|
|
||||||
# also match the internal/secret/ package directory.
|
|
||||||
/secret
|
/secret
|
||||||
*.log
|
*.log
|
||||||
|
cli.test
|
||||||
|
vault.test
|
||||||
*.test
|
*.test
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|
||||||
|
# Stale files
|
||||||
|
.cursorrules
|
||||||
|
coverage.out
|
||||||
|
|||||||
+2
-66
@@ -10,20 +10,14 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
enable:
|
|
||||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
|
||||||
# left to `default: all`, because it carries the module policy below.
|
|
||||||
- gomodguard_v2
|
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
|
- depguard # Dependency allow/block lists
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
# Deprecated: the warning is attached to the old name, so it is
|
|
||||||
# silenced by disabling that name, not by enabling the successor.
|
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -34,64 +28,6 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
depguard:
|
|
||||||
# Test-support code must not be compiled into the shipped binary. A
|
|
||||||
# test-support package exists to hand a test privileges the program
|
|
||||||
# itself must never have, so a file that is not a test must not import
|
|
||||||
# one. Test files, and the files inside a package whose directory name
|
|
||||||
# ends in `test`, are where that code belongs, and are exempt.
|
|
||||||
#
|
|
||||||
# The deny list below is the one part of this file a repository is
|
|
||||||
# expected to extend, and the only part it may. depguard matches an
|
|
||||||
# import path against a list of prefixes, so it cannot be told "any path
|
|
||||||
# whose last segment ends in test"; a repository's own test-support
|
|
||||||
# packages have to be named here one at a time, by full import path,
|
|
||||||
# under a module path that differs from repository to repository. Add
|
|
||||||
# them; change nothing else.
|
|
||||||
rules:
|
|
||||||
test-support:
|
|
||||||
list-mode: lax
|
|
||||||
files:
|
|
||||||
- "$all"
|
|
||||||
- "!$test"
|
|
||||||
- "!**/*test/**"
|
|
||||||
deny:
|
|
||||||
- pkg: net/http/httptest
|
|
||||||
desc: >-
|
|
||||||
Test-support code belongs in test files and in packages whose
|
|
||||||
directory name ends in test, not in the shipped binary.
|
|
||||||
# Only decisions already recorded in the Go package defaults are
|
|
||||||
# listed here. Every entry matches the module path exactly.
|
|
||||||
gomodguard_v2:
|
|
||||||
blocked:
|
|
||||||
- module: github.com/rs/zerolog
|
|
||||||
recommendations:
|
|
||||||
- log/slog
|
|
||||||
reason: "Structured logging is stdlib log/slog."
|
|
||||||
# One entry per pre-fork module path, because the later releases
|
|
||||||
# are separate paths. A prefix match would be shorter but would
|
|
||||||
# also reach github.com/go-redis/redismock, the test double for
|
|
||||||
# the successor these entries recommend.
|
|
||||||
- module: github.com/go-redis/redis
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/go-redis/redis/v7
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/go-redis/redis/v8
|
|
||||||
recommendations:
|
|
||||||
- github.com/redis/go-redis/v9
|
|
||||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
|
||||||
- module: github.com/sergi/go-diff
|
|
||||||
recommendations:
|
|
||||||
- github.com/aymanbagabas/go-udiff
|
|
||||||
reason: "No unified diff output; use go-udiff."
|
|
||||||
- module: github.com/hexops/gotextdiff
|
|
||||||
recommendations:
|
|
||||||
- github.com/aymanbagabas/go-udiff
|
|
||||||
reason: "Unmaintained fork; use go-udiff."
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
+1
-10
@@ -6,16 +6,10 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
|
|
||||||
# below run again on each build, an unchanged tree included, while the
|
|
||||||
# steps above stay cached. ARG is per stage: the build stage declares it too.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
# Not make lint: script/lint is a docker build, which cannot run in here.
|
RUN make lint
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
|
||||||
# Build stage — tests and compilation
|
# Build stage — tests and compilation
|
||||||
# golang 1.24.13-alpine (2026-03-10)
|
# golang 1.24.13-alpine (2026-03-10)
|
||||||
@@ -30,9 +24,6 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# As in the lint stage: the RUN steps below run again on each script/cibuild.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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 ./...
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
|
export DOCKER_HOST := ssh://root@ber1app1.local
|
||||||
|
|
||||||
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
|
# Version information
|
||||||
docker-run clean install hooks
|
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
|
||||||
|
|
||||||
default: check
|
default: check
|
||||||
|
|
||||||
@@ -11,9 +17,13 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
|
build: ./secret
|
||||||
build:
|
|
||||||
@script/build
|
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
|
||||||
|
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
@@ -39,7 +49,7 @@ docker-run:
|
|||||||
clean:
|
clean:
|
||||||
rm -f ./secret
|
rm -f ./secret
|
||||||
|
|
||||||
install: build
|
install: ./secret
|
||||||
cp ./secret $(HOME)/bin/secret
|
cp ./secret $(HOME)/bin/secret
|
||||||
|
|
||||||
fmt-check:
|
fmt-check:
|
||||||
|
|||||||
@@ -91,9 +91,6 @@ Lists all available vaults. The current vault is marked.
|
|||||||
|
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
|
|
||||||
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
|
|
||||||
are allowed, and a name must not be empty, `.` or `..`.
|
|
||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
@@ -142,9 +139,6 @@ matching.
|
|||||||
|
|
||||||
Moves or renames a secret within the current vault.
|
Moves or renames a secret within the current vault.
|
||||||
- Fails if the destination already exists
|
- Fails if the destination already exists
|
||||||
- Fails if the destination is the source under another name, such as `foo`
|
|
||||||
for `Foo` on a case-insensitive filesystem (the macOS default); there, to
|
|
||||||
change only the case of a name, move the secret to a third name first
|
|
||||||
- Preserves all versions and metadata
|
- Preserves all versions and metadata
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
@@ -197,16 +191,11 @@ Creates a new unlocker of the specified type:
|
|||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
||||||
|
|
||||||
A vault has one passphrase unlocker: adding one replaces the one the vault
|
|
||||||
has, which is removed only once the new one is the current unlocker.
|
|
||||||
|
|
||||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
|
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
|
||||||
does not ask for confirmation. Cannot remove the last unlocker if the vault
|
does not ask for confirmation. Cannot remove the last unlocker if the vault
|
||||||
has secrets unless --force is used. An unlocker directory that
|
has secrets unless --force is used.
|
||||||
`secret unlocker list` skips with a warning, because its metadata cannot be
|
|
||||||
read or parsed, is removed by the directory name the warning gives.
|
|
||||||
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
||||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||||
vault data will be PERMANENTLY INACCESSIBLE
|
vault data will be PERMANENTLY INACCESSIBLE
|
||||||
@@ -246,8 +235,8 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
├── vaults.d/
|
├── vaults.d/
|
||||||
│ ├── default/
|
│ ├── default/
|
||||||
│ │ ├── unlockers.d/
|
│ │ ├── unlockers.d/
|
||||||
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
|
│ │ │ ├── passphrase/ # Passphrase unlocker
|
||||||
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
|
│ │ │ └── pgp/ # PGP unlocker
|
||||||
│ │ ├── secrets.d/
|
│ │ ├── secrets.d/
|
||||||
│ │ │ ├── api%key/ # Secret: api/key
|
│ │ │ ├── api%key/ # Secret: api/key
|
||||||
│ │ │ │ ├── versions/
|
│ │ │ │ ├── versions/
|
||||||
@@ -263,7 +252,7 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ └── current -> versions/20231215.001
|
│ │ │ └── current -> versions/20231215.001
|
||||||
│ │ ├── vault-metadata.json # Vault metadata
|
│ │ ├── vault-metadata.json # Vault metadata
|
||||||
│ │ ├── pub.age # Long-term public key
|
│ │ ├── pub.age # Long-term public key
|
||||||
│ │ └── current-unlocker # Current unlocker's directory name
|
│ │ └── current-unlocker -> ../unlockers.d/passphrase
|
||||||
│ └── work/
|
│ └── work/
|
||||||
│ ├── unlockers.d/
|
│ ├── unlockers.d/
|
||||||
│ ├── secrets.d/
|
│ ├── secrets.d/
|
||||||
@@ -321,18 +310,6 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
||||||
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
||||||
|
|
||||||
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
|
|
||||||
they hold. Other processes running as the same user can read a process's
|
|
||||||
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
|
||||||
shell or script that sets them inherits them, `gpg` included. Set on a command
|
|
||||||
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
|
||||||
each one as soon as it has read it, so that the programs it runs itself, such
|
|
||||||
as `gpg`, do not inherit it, but that erases nothing: the environment the
|
|
||||||
process started with, and its memory, still hold the value. The interactive
|
|
||||||
prompt, which every command except `secret vault import` offers when the
|
|
||||||
variable is not set, is the safer default; `secret vault import` has no prompt
|
|
||||||
and needs both variables.
|
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
@@ -519,21 +496,15 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (Go, Go module
|
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
|
||||||
download), idempotently; golangci-lint is not installed, it runs in
|
module download), idempotently
|
||||||
docker
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
- `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
- `script/projectname` — output the project name (`secret`); used by
|
- `script/projectname` — output the project name (`secret`); used by
|
||||||
other scripts such as `script/docker`
|
other scripts such as `script/docker`
|
||||||
- `script/build` — build the `secret` binary into the repo root, stamping
|
|
||||||
the version (`VERSION` from the environment, else `git describe`) and
|
|
||||||
the git commit
|
|
||||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
||||||
failure)
|
failure)
|
||||||
- `script/lint` — run `golangci-lint` in docker only: builds
|
- `script/lint` — run `golangci-lint`
|
||||||
`Dockerfile.lint`, where the linter is a build step that runs on every
|
|
||||||
call, also on an unchanged tree
|
|
||||||
- `script/fmt` — format all Go code (writes)
|
- `script/fmt` — format all Go code (writes)
|
||||||
- `script/fmt-check` — check formatting without writing
|
- `script/fmt-check` — check formatting without writing
|
||||||
- `script/check` — run `script/test`, `script/lint`, and
|
- `script/check` — run `script/test`, `script/lint`, and
|
||||||
@@ -541,8 +512,7 @@ them. We provide:
|
|||||||
- `script/docker` — build the Docker image tagged with the project name
|
- `script/docker` — build the Docker image tagged with the project name
|
||||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
||||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
||||||
checks), with a new `CHECK_EPOCH` build argument on every run so the
|
checks)
|
||||||
checks run again on an unchanged tree
|
|
||||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
||||||
then `script/check`
|
then `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
|
|||||||
@@ -25,192 +25,6 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
|
||||||
current unlocker that cannot open the vault
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
|
||||||
directory of its own, named with the time to the nanosecond:
|
|
||||||
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
|
|
||||||
Enclave unlocker the keychain item or Secure Enclave key, which names the
|
|
||||||
directory, carries the time instead of the day. `secret.WriteDir` fails on a
|
|
||||||
directory that exists instead of writing into it. `unlocker add passphrase`
|
|
||||||
writes the new unlocker, makes it current, and only then removes the vault's
|
|
||||||
other passphrase unlockers; a crash between the last two steps leaves the old
|
|
||||||
one beside the new, and the old passphrase still opens the vault through it
|
|
||||||
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
|
|
||||||
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
|
|
||||||
another of its type is added beside it instead of replacing it.
|
|
||||||
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
|
|
||||||
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
|
|
||||||
unset at once, so that no program the command runs, `gpg` included,
|
|
||||||
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
|
|
||||||
the command reads the environment; the buffers are passed down:
|
|
||||||
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
|
||||||
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
|
||||||
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
|
||||||
constructors take both. `CreatePGPUnlocker` sets both on the vault it
|
|
||||||
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
|
|
||||||
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
|
|
||||||
`vault create` no longer put the mnemonic into the environment. Unsetting
|
|
||||||
erases nothing: the starting environment (`/proc/<pid>/environ`) and
|
|
||||||
memory still hold the value. The README warns against both variables.
|
|
||||||
- 2026-10-04: `.golangci.yml` is again the canonical file from
|
|
||||||
`sneak/prompts`, byte for byte
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
|
|
||||||
in place of the deprecated `gomodguard`, so the lint no longer warns,
|
|
||||||
and enables `depguard` with a rule that keeps `net/http/httptest` out of
|
|
||||||
non-test files. Neither raised a finding in this repo.
|
|
||||||
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
|
|
||||||
the vault's long-term key as adding a passphrase unlocker does, with the
|
|
||||||
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
|
|
||||||
mnemonic, checked against the vault, or else from the current unlocker.
|
|
||||||
Before, it used the keychain unlocker's helper, which on every platform
|
|
||||||
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
|
|
||||||
key, getting the long-term key once from the mnemonic and once from a
|
|
||||||
passphrase unlocker, and reads a secret through the new unlocker.
|
|
||||||
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
|
||||||
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
|
||||||
state the rule. `vault create`, `vault import`, `vault select`,
|
|
||||||
`vault remove`, both vault names of `mv` and shell completion of a
|
|
||||||
`vault:secret` argument check the name as typed with
|
|
||||||
`vault.ValidateVaultName` before building any path from it. Before,
|
|
||||||
`vault import ..` wrote a long-term key and an unlocker into the state
|
|
||||||
directory itself, and `vault select ..` made that the current vault.
|
|
||||||
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
|
||||||
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
|
||||||
current time as the `CHECK_EPOCH` build argument, which both the lint
|
|
||||||
and the build stage of the `Dockerfile` declare after their module
|
|
||||||
download, so the `RUN` steps below the argument run again on each
|
|
||||||
build while the base images and module downloads stay cached. Before,
|
|
||||||
a second run on the same tree took every check from the build cache
|
|
||||||
and reported success having run nothing.
|
|
||||||
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
|
||||||
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
|
||||||
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for
|
|
||||||
its duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
|
||||||
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
|
|
||||||
and encrypt everything before writing anything. All four unlocker
|
|
||||||
types write their files through `secret.WriteDir`: a new unlocker is
|
|
||||||
built in a temporary directory, renamed into place when complete and
|
|
||||||
removed on a failure.
|
|
||||||
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
|
|
||||||
skip, with the warning `unlocker list` gives, an unlocker directory
|
|
||||||
whose metadata file cannot be checked for, read or parsed, instead of
|
|
||||||
failing when it sorts before the unlocker asked for. Such a directory,
|
|
||||||
or one without a metadata file, is removed by its directory name, the
|
|
||||||
name the warning gives; only the directory is removed, since its type
|
|
||||||
is unknown. Removing one whose metadata file is missing or corrupt
|
|
||||||
never counts as removing the last unlocker. Removing one whose metadata
|
|
||||||
file cannot be checked for or read always does, since it may be the
|
|
||||||
only working unlocker, so in a vault with secrets it needs `--force`.
|
|
||||||
- 2026-10-04: A failed command prints its error once, without the usage
|
|
||||||
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
|
|
||||||
still printed for a command called wrongly: wrong number of arguments,
|
|
||||||
unknown flag, bad flag value, missing required flag, or flags that
|
|
||||||
break a flag group (mutually exclusive, required together, one
|
|
||||||
required). The root command's `PersistentPreRunE` turns usage off.
|
|
||||||
Cobra checks arguments and flag values before that hook but required
|
|
||||||
flags and flag groups only after it, so the hook checks those two
|
|
||||||
first. Root `SilenceUsage` would have hidden usage for all of these.
|
|
||||||
- 2026-10-04: `secret get` keeps the secret in locked memory until it
|
|
||||||
writes it out (https://git.eeqj.de/sneak/secret/issues/37):
|
|
||||||
`Vault.GetSecret` and `Vault.GetSecretVersion` return a
|
|
||||||
`*memguard.LockedBuffer`, which every caller destroys, and `secret get`
|
|
||||||
writes its bytes straight to stdout, still with no trailing newline.
|
|
||||||
Before, the value was copied into ordinary memory that nothing wiped,
|
|
||||||
and `get --version` also wrote it to the debug log.
|
|
||||||
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
|
|
||||||
targets use the local docker daemon, or whatever `DOCKER_HOST` the
|
|
||||||
environment sets. `make build` calls the new `script/build`, which
|
|
||||||
stamps the version (`VERSION` from the environment, else
|
|
||||||
`git describe`) and the git commit as before. `build`, `clean`,
|
|
||||||
`install` and `docker-run` are in `.PHONY`; `make install` depends on
|
|
||||||
`build`. The `vet` target is gone: `script/test` runs `go vet` first.
|
|
||||||
- 2026-10-04: `.gitignore` is the org's standard file, which ignores
|
|
||||||
`.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus
|
|
||||||
this repo's `/secret`, `*.log`, `*.test` and `settings.local.json`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also
|
|
||||||
leaves out `node_modules`; `.git` stays in the build context for the
|
|
||||||
version stamp.
|
|
||||||
- 2026-10-04: `secret init` refuses when the default vault exists, and
|
|
||||||
`secret vault create NAME` when `NAME` does, with "vault NAME already
|
|
||||||
exists", before writing anything. The check is in `vault.CreateVault`,
|
|
||||||
which both commands call while holding the state directory lock, so two
|
|
||||||
creates of one vault at once cannot both pass the check. Before, either
|
|
||||||
command replaced the vault's metadata, passphrase unlocker and
|
|
||||||
`longterm.age`, so none of its secrets could be decrypted any more. Both
|
|
||||||
commands now ask for the unlocker passphrase before creating the vault,
|
|
||||||
so one stopped at that prompt leaves no vault behind.
|
|
||||||
- 2026-10-04: The `internal/cli` tests are back to about their time
|
|
||||||
before the state directory lock
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/80). The test that each
|
|
||||||
changing command waits for the lock releases it as soon as it sees the
|
|
||||||
command waiting there, instead of after a fixed 100 ms. The two vaults
|
|
||||||
with passphrase unlockers that the path and move tests start from are
|
|
||||||
made once and copied for each test.
|
|
||||||
- 2026-10-04: `secret mv` rejects a move whose destination is the source
|
|
||||||
under another name, such as `foo` for `Foo` on a case-insensitive
|
|
||||||
filesystem (the macOS default) or a name reached through a symbolic
|
|
||||||
link, before changing anything, with or without `--force`, within a
|
|
||||||
vault and between vaults; before, `--force` removed the destination and
|
|
||||||
so deleted the secret. A rename that changes only letter case works on a
|
|
||||||
case-sensitive filesystem as before.
|
|
||||||
- 2026-10-04: Lint runs only in docker: `script/lint` builds
|
|
||||||
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on
|
|
||||||
every run (`--no-cache-filter`), so an unchanged tree is linted too;
|
|
||||||
the module download stays cached. `script/bootstrap` no longer
|
|
||||||
installs golangci-lint, and the `Dockerfile` lint stage calls it
|
|
||||||
directly instead of `make lint`. `golangci-lint config verify` is not
|
|
||||||
run: it fetches its schema live over unpinned HTTPS.
|
|
||||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
|
||||||
no longer panics: `GetID()` warns with the unlocker's directory and
|
|
||||||
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
|
||||||
unlocker whose metadata file cannot be checked for, read or parsed
|
|
||||||
instead of failing, so `secret unlocker list` still lists the others;
|
|
||||||
the listing's ID lookup no longer warns about that directory again.
|
|
||||||
- 2026-10-03: `secret mv` rejects a move whose destination is the
|
|
||||||
source (`mv --force x x`, `mv --force work:x work:`, or an empty
|
|
||||||
destination, which defaults to the source name) before changing
|
|
||||||
anything; before, `--force` removed the destination first and so
|
|
||||||
deleted the secret. Every vault name given with `vault:` must be one
|
|
||||||
of the existing vaults by exact name, so `work:x work/:x` is rejected
|
|
||||||
instead of being taken for a move between two vaults. A move within a
|
|
||||||
named vault no longer makes that vault the current one, whether it
|
|
||||||
succeeds or fails.
|
|
||||||
- 2026-10-03: Commands that change the state directory hold one lock
|
|
||||||
(`flock` on `lock` in the state directory; a mutex on the in-memory
|
|
||||||
test filesystem), so concurrent commands no longer lose versions or
|
|
||||||
race on the current pointers. Every file is written through
|
|
||||||
`secret.WriteFileAtomic` (temporary file, sync, rename), so no file
|
|
||||||
is ever half-written and `current`, `currentvault` and
|
|
||||||
`current-unlocker` never go missing. New versions, new secrets and
|
|
||||||
cross-vault copies are built in a temporary directory and renamed
|
|
||||||
into place, and removals rename out of the way first, so a version
|
|
||||||
or secret is never half-added and never half-removed. An
|
|
||||||
interrupted command can still leave:
|
|
||||||
- from `init` or `vault create` killed after the passphrase prompt
|
|
||||||
but before the unlocker is written, a vault with no unlocker,
|
|
||||||
which `vault create` has already made the current vault;
|
|
||||||
- data under a `.tmp-` name in the state directory: a secret,
|
|
||||||
version or unlocker being added, or the secret, version, unlocker
|
|
||||||
or vault being removed, encrypted keys included. Nothing deletes
|
|
||||||
it; it must be deleted by hand
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/75).
|
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an
|
|
||||||
error naming the path and cause when they cannot read what they
|
|
||||||
inspect, instead of reading the failure as "nothing there": the
|
|
||||||
duplicate check before `unlocker add pgp` (an unreadable
|
|
||||||
`unlockers.d` or unlocker metadata file), the secret count that
|
|
||||||
guards removing the last unlocker and removing a vault, and the
|
|
||||||
existing long-term key check before `vault import`.
|
|
||||||
- 2026-10-03: `version rm`, `version promote` and `get --version`
|
|
||||||
accept a version only if it is one of the versions `version list`
|
|
||||||
lists for that secret, compared as typed before any path is built
|
|
||||||
(`secret.VersionExists`), and touch nothing otherwise. An empty
|
|
||||||
`--version` is rejected instead of meaning the current version.
|
|
||||||
Before, `secret version rm x ../../..` deleted the whole vault,
|
|
||||||
`secret version rm x ..` the secret, and `.` or `""` every version.
|
|
||||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
||||||
the exit code after its deferred `memguard.Purge()` has run, and only
|
the exit code after its deferred `memguard.Purge()` has run, and only
|
||||||
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
||||||
@@ -298,11 +112,17 @@ Bring the repo into policy compliance in one commit:
|
|||||||
version.go:155); age secret key held in a plain string in
|
version.go:155); age secret key held in a plain string in
|
||||||
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
|
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
|
||||||
to GPGEncryptFunc and EncryptWithPassphrase.
|
to GPGEncryptFunc and EncryptWithPassphrase.
|
||||||
|
- Race conditions: no file locking in vault/secrets.go:142-176;
|
||||||
|
non-atomic writes can leave the vault inconsistent.
|
||||||
- Input validation: no maximum secret size (DoS).
|
- Input validation: no maximum secret size (DoS).
|
||||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
||||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
209-216); non-constant-time public key compare (vault.go:95-100).
|
||||||
- High priority:
|
- High priority:
|
||||||
|
- Return errors instead of panicking on corrupted metadata
|
||||||
|
(pgpunlocker.go:116, keychainunlocker.go:141).
|
||||||
- Secure temporary file handling and cleanup.
|
- Secure temporary file handling and cleanup.
|
||||||
|
- Print cobra usage only for argument errors, not internal
|
||||||
|
failures.
|
||||||
- Initialize a default unlock key at vault creation.
|
- Initialize a default unlock key at vault creation.
|
||||||
- Confirmation prompts for destructive operations (keys rm, vault
|
- Confirmation prompts for destructive operations (keys rm, vault
|
||||||
deletion).
|
deletion).
|
||||||
@@ -313,6 +133,8 @@ Bring the repo into policy compliance in one commit:
|
|||||||
suggestions.
|
suggestions.
|
||||||
- Validate GPG key existence before creating PGP unlock keys.
|
- Validate GPG key existence before creating PGP unlock keys.
|
||||||
- Split oversized CLI functions.
|
- Split oversized CLI functions.
|
||||||
|
- Document env var security (SB_UNLOCK_PASSPHRASE,
|
||||||
|
SB_SECRET_MNEMONIC); clear after use.
|
||||||
- mlock/munlock for sensitive allocations.
|
- mlock/munlock for sensitive allocations.
|
||||||
- Cleanups: read statedir from environment or default instead of
|
- Cleanups: read statedir from environment or default instead of
|
||||||
passing it around.
|
passing it around.
|
||||||
|
|||||||
+3
-45
@@ -3,10 +3,8 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -16,11 +14,6 @@ type Instance struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
|
|
||||||
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
|
|
||||||
// none.
|
|
||||||
Mnemonic *memguard.LockedBuffer
|
|
||||||
UnlockPassphrase *memguard.LockedBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||||
@@ -76,42 +69,7 @@ func (cli *Instance) GetStateDir() string {
|
|||||||
return cli.stateDir
|
return cli.stateDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
|
// Print outputs to the command's configured output writer
|
||||||
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
|
func (cli *Instance) Print(a ...any) (int, error) {
|
||||||
// either calls it once, before anything else, and passes the buffers on
|
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||||
// from there: each variable is unset as soon as it is read, so that the
|
|
||||||
// processes this one starts, gpg among them, do not inherit it, and a
|
|
||||||
// second read would find nothing. The returned function destroys both
|
|
||||||
// buffers.
|
|
||||||
func (cli *Instance) readSecretEnv() func() {
|
|
||||||
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
|
|
||||||
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
|
|
||||||
|
|
||||||
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
if mnemonic != nil {
|
|
||||||
mnemonic.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if passphrase != nil {
|
|
||||||
passphrase.Destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAndUnsetEnv returns the value of the environment variable name in a
|
|
||||||
// locked buffer, or nil when it is unset or empty, and unsets the variable.
|
|
||||||
// Unsetting does not erase the value: it stays in this process's memory,
|
|
||||||
// and in /proc/<pid>/environ, which shows the environment the process
|
|
||||||
// started with. The caller must destroy the returned buffer.
|
|
||||||
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
|
|
||||||
value := os.Getenv(name)
|
|
||||||
_ = os.Unsetenv(name)
|
|
||||||
|
|
||||||
if value == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes([]byte(value))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,7 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||||
// colon is present in the input. It completes nothing when the vault part
|
// colon is present in the input
|
||||||
// is not a valid vault name, so that a name such as ".." cannot list a
|
|
||||||
// directory outside vaults.d.
|
|
||||||
func completeVaultQualifiedSecrets(
|
func completeVaultQualifiedSecrets(
|
||||||
fs afero.Fs, stateDir, toComplete string,
|
fs afero.Fs, stateDir, toComplete string,
|
||||||
) []string {
|
) []string {
|
||||||
@@ -136,10 +134,6 @@ func completeVaultQualifiedSecrets(
|
|||||||
vaultName := parts[0]
|
vaultName := parts[0]
|
||||||
secretPrefix := parts[1]
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
if vault.ValidateVaultName(vaultName) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
|
||||||
secrets, err := vlt.ListSecrets()
|
secrets, err := vlt.ListSecrets()
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
|
|
||||||
// argument lists nothing when the vault part is not a valid vault name, even
|
|
||||||
// where that name, joined onto vaults.d, leads to a secrets.d directory.
|
|
||||||
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
stateDir = "/state"
|
|
||||||
dirPerm = 0o700
|
|
||||||
)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
// The vault "work" holds the secret "x". So does every directory an
|
|
||||||
// invalid name below would lead to from vaults.d.
|
|
||||||
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
|
|
||||||
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
|
|
||||||
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"work:x"},
|
|
||||||
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
|
|
||||||
|
|
||||||
for _, toComplete := range []string{".:", "..:", "a/b:"} {
|
|
||||||
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
|
||||||
"completing %q", toComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCreateExistingVaultChangesNothing is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
|
|
||||||
// a second time, or `secret vault create` with the name of an existing
|
|
||||||
// vault, replaced that vault's keys, so that none of its secrets could be
|
|
||||||
// decrypted any more. Each must refuse, change nothing, and leave every
|
|
||||||
// vault's secret readable through its passphrase unlocker.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
// newCLI returns an instance on fs given the mnemonic and the unlock
|
|
||||||
// passphrase, as from the environment
|
|
||||||
newCLI := func(fs afero.Fs) *cli.Instance {
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
c.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// `secret init`, `secret vault create work`, `secret vault select
|
|
||||||
// default`, and the secret "x" in each vault. "work" is then not the
|
|
||||||
// current vault, which creating it again must not change.
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
c := newCLI(fs)
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
require.NoError(t, c.Init(cmd))
|
|
||||||
require.NoError(t, c.CreateVault(cmd, "work"))
|
|
||||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
|
||||||
|
|
||||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, vaults, 2)
|
|
||||||
|
|
||||||
for _, name := range vaults {
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
||||||
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, fs)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
want string
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"init",
|
|
||||||
"failed to create default vault: vault default already exists",
|
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create default",
|
|
||||||
"vault default already exists",
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create work",
|
|
||||||
"vault work already exists",
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
|
|
||||||
err := tt.run(newCLI(fs))
|
|
||||||
|
|
||||||
require.EqualError(t, err, tt.want)
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every case left the state directory exactly as recorded in before, so
|
|
||||||
// reading each vault's secret once from it shows that it still decrypts
|
|
||||||
// after each case. Without the mnemonic, reading a secret goes through
|
|
||||||
// the vault's passphrase unlocker, which is slow.
|
|
||||||
for _, name := range vaults {
|
|
||||||
vlt := vault.NewVault(fs, testStateDir, name)
|
|
||||||
vlt.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
unchanged := bytes.Equal([]byte("value"), value.Bytes())
|
|
||||||
value.Destroy()
|
|
||||||
|
|
||||||
require.True(t, unchanged, "vault %q kept its secret", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
|
|
||||||
// `secret vault create` put the mnemonic into the process environment,
|
|
||||||
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
|
|
||||||
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
|
|
||||||
// leave neither in the environment.
|
|
||||||
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
|
||||||
t.Setenv(secret.EnvStateDir, t.TempDir())
|
|
||||||
|
|
||||||
run := func(args ...string) {
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
// With no terminal to prompt on, this succeeds only if the command
|
|
||||||
// read both variables
|
|
||||||
_, err := cli.ExecuteCommandInProcess(args, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
|
|
||||||
_, set := os.LookupEnv(name)
|
|
||||||
require.False(t, set, "%s is set after %v", name, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run("init")
|
|
||||||
run("vault", "create", "work")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
|
||||||
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
|
||||||
// `secret vault create` stopped at the passphrase prompt left a vault with
|
|
||||||
// no unlocker, which neither command would then create again. Each must ask
|
|
||||||
// for the passphrase before writing anything.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// An empty state directory for `secret init`, and one holding the vault
|
|
||||||
// "default" for `secret vault create work`.
|
|
||||||
empty := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
|
||||||
|
|
||||||
withDefault := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
fs afero.Fs
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"init",
|
|
||||||
empty,
|
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault create work",
|
|
||||||
withDefault,
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, tt.fs)
|
|
||||||
|
|
||||||
// Given no unlock passphrase, both commands prompt for it, which
|
|
||||||
// fails because the tests do not run in a terminal.
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
err := tt.run(c)
|
|
||||||
|
|
||||||
require.ErrorContains(t, err, "failed to read passphrase")
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, tt.fs))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-52
@@ -41,9 +41,6 @@ func newCryptoCmd(
|
|||||||
|
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return run(cli, args[0], inputFile, outputFile)
|
return run(cli, args[0], inputFile, outputFile)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -73,44 +70,9 @@ func newDecryptCmd() *cobra.Command {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
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
|
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
||||||
// for the named secret, generating and storing a new key if the secret
|
// 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
|
// does not exist. The caller must destroy the returned buffer.
|
||||||
// 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(
|
func (cli *Instance) resolveEncryptionKey(
|
||||||
vlt *vault.Vault, secretName string,
|
vlt *vault.Vault, secretName string,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
@@ -123,11 +85,23 @@ func (cli *Instance) resolveEncryptionKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
key, err := cli.storeNewEncryptionKey(vlt, secretName)
|
// Secret doesn't exist, generate new age key and store it
|
||||||
if !errors.Is(err, vault.ErrSecretExists) {
|
identity, err := age.GenerateX25519Identity()
|
||||||
return key, err
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||||
}
|
}
|
||||||
// Another command stored the key since the check above: read it
|
|
||||||
|
// Store the generated key directly in a secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||||
|
if err != nil {
|
||||||
|
secureBuffer.Destroy()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to store age key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret exists, get the age secret key from it
|
// Secret exists, get the age secret key from it
|
||||||
@@ -159,8 +133,6 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Get or create the age secret key for this secret
|
// Get or create the age secret key for this secret
|
||||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,8 +207,6 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
|
||||||
@@ -315,13 +285,13 @@ func isValidAgeSecretKey(key string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret with the vault's mnemonic
|
// getSecretValue retrieves the value of a secret using the appropriate
|
||||||
// when it has one, else with the current unlocker
|
// unlocker
|
||||||
func (cli *Instance) getSecretValue(
|
func (cli *Instance) getSecretValue(
|
||||||
vlt *vault.Vault, secretObj *secret.Secret,
|
vlt *vault.Vault, secretObj *secret.Secret,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
if vlt.Mnemonic != nil {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
return secretObj.GetValue(nil, vlt.Mnemonic)
|
return secretObj.GetValue(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker, err := vlt.GetCurrentUnlocker()
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
@@ -329,5 +299,5 @@ func (cli *Instance) getSecretValue(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return secretObj.GetValue(unlocker, nil)
|
return secretObj.GetValue(unlocker)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -158,20 +155,12 @@ func (cli *Instance) GenerateSecret(
|
|||||||
return fmt.Errorf("failed to generate random secret: %w", err)
|
return fmt.Errorf("failed to generate random secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Store the secret in the vault
|
// Store the secret in the vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Protect the generated secret immediately
|
// Protect the generated secret immediately
|
||||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||||
defer secretBuffer.Destroy()
|
defer secretBuffer.Destroy()
|
||||||
|
|||||||
+23
-38
@@ -39,20 +39,16 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
|||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.Init(cmd)
|
return cli.Init(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
|
// promptMnemonic reads the mnemonic from the environment or interactively.
|
||||||
// or reads it interactively. The returned cleanup function must be deferred
|
// The returned cleanup function must be deferred by the caller.
|
||||||
// by the caller.
|
func promptMnemonic() (string, func(), error) {
|
||||||
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
if cli.Mnemonic != nil {
|
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
|
||||||
return cli.Mnemonic, func() {}, nil
|
return envMnemonic, func() {}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
@@ -62,23 +58,23 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
|
|
||||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupDefaultVault creates the default vault and derives its long-term
|
// setupDefaultVault creates the default vault and derives its long-term
|
||||||
// identity from the mnemonic
|
// identity from the mnemonic
|
||||||
func (cli *Instance) setupDefaultVault(
|
func (cli *Instance) setupDefaultVault(
|
||||||
stateDir string, mnemonic *memguard.LockedBuffer,
|
stateDir, mnemonicStr string,
|
||||||
) (*vault.Vault, *age.X25519Identity, error) {
|
) (*vault.Vault, *age.X25519Identity, error) {
|
||||||
// Create the default vault - it will handle key derivation internally
|
// Create the default vault - it will handle key derivation internally
|
||||||
secret.Debug("Creating default vault")
|
secret.Debug("Creating default vault")
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
@@ -96,7 +92,7 @@ func (cli *Instance) setupDefaultVault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
// Derive the long-term key using the same index that CreateVault used
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to derive long-term key", "error", err)
|
secret.Debug("Failed to derive long-term key", "error", err)
|
||||||
|
|
||||||
@@ -107,21 +103,8 @@ func (cli *Instance) setupDefaultVault(
|
|||||||
return vlt, ltIdentity, nil
|
return vlt, ltIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the secret manager, holding the state directory lock
|
// Init initializes the secret manager
|
||||||
// while initialize runs
|
|
||||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.initialize(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize creates the state directory, the default vault and its first
|
|
||||||
// unlocker
|
|
||||||
func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|
||||||
secret.Debug("Starting secret manager initialization")
|
secret.Debug("Starting secret manager initialization")
|
||||||
|
|
||||||
// Create state directory
|
// Create state directory
|
||||||
@@ -140,13 +123,12 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for mnemonic
|
// Prompt for mnemonic
|
||||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cleanupMnemonic()
|
defer cleanupMnemonic()
|
||||||
|
|
||||||
mnemonicStr := mnemonic.String()
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
secret.Debug("Empty mnemonic provided")
|
secret.Debug("Empty mnemonic provided")
|
||||||
|
|
||||||
@@ -165,16 +147,12 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
errInvalidMnemonicPhrase)
|
errInvalidMnemonicPhrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for the unlocker passphrase before creating the vault, so that
|
// Set mnemonic in environment for CreateVault to use
|
||||||
// stopping at the prompt leaves no vault without an unlocker behind
|
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
defer restoreMnemonicEnv()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanupPassphrase()
|
|
||||||
|
|
||||||
// Create the default vault and derive its long-term key
|
// Create the default vault and derive its long-term key
|
||||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
|
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -184,6 +162,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Prompt for passphrase for unlocker
|
||||||
|
passphraseBuffer, err := resolvePassphrase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// Test 25: Concurrent operations
|
// Test 25: Concurrent operations
|
||||||
// Purpose: Test multiple simultaneous operations
|
// Purpose: Test multiple simultaneous operations
|
||||||
// Expected: Proper locking/synchronization, no corruption
|
// Expected: Proper locking/synchronization, no corruption
|
||||||
test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
|
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv)
|
||||||
|
|
||||||
// Test 26: Large secret values
|
// Test 26: Large secret values
|
||||||
// Purpose: Test with large secret values (e.g., certificates)
|
// Purpose: Test with large secret values (e.g., certificates)
|
||||||
@@ -366,15 +366,8 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
|
||||||
verifyFileExists(t, unlockersDir)
|
verifyFileExists(t, unlockersDir)
|
||||||
|
|
||||||
// Check current-unlocker file names the unlocker's directory
|
|
||||||
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
|
||||||
verifyFileExists(t, currentUnlockerFile)
|
|
||||||
|
|
||||||
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
|
||||||
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
|
|
||||||
|
|
||||||
// Verify passphrase unlocker was created
|
// Verify passphrase unlocker was created
|
||||||
passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
|
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase")
|
||||||
verifyFileExists(t, passphraseUnlockerDir)
|
verifyFileExists(t, passphraseUnlockerDir)
|
||||||
|
|
||||||
// Check unlocker metadata
|
// Check unlocker metadata
|
||||||
@@ -389,6 +382,13 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
||||||
verifyFileExists(t, encryptedLTPubKey)
|
verifyFileExists(t, encryptedLTPubKey)
|
||||||
|
|
||||||
|
// Check current-unlocker file contains the relative path
|
||||||
|
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
||||||
|
verifyFileExists(t, currentUnlockerFile)
|
||||||
|
|
||||||
|
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
||||||
|
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
|
||||||
|
|
||||||
// Verify vault-metadata.json in vault
|
// Verify vault-metadata.json in vault
|
||||||
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
||||||
verifyFileExists(t, vaultMetadata)
|
verifyFileExists(t, vaultMetadata)
|
||||||
@@ -537,8 +537,7 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
|
|||||||
verifyFileExists(t, pubKeyFile)
|
verifyFileExists(t, pubKeyFile)
|
||||||
|
|
||||||
// Verify passphrase unlocker was created
|
// Verify passphrase unlocker was created
|
||||||
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
|
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase")
|
||||||
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
|
|
||||||
verifyFileExists(t, passphraseUnlockerDir)
|
verifyFileExists(t, passphraseUnlockerDir)
|
||||||
|
|
||||||
// Check unlocker files
|
// Check unlocker files
|
||||||
@@ -830,14 +829,6 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
|
|||||||
|
|
||||||
require.NoError(t, err, "get current version should succeed")
|
require.NoError(t, err, "get current version should succeed")
|
||||||
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
|
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)) {
|
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
@@ -2010,35 +2001,28 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
|
|||||||
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
|
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
|
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Make sure we're in default vault
|
// Make sure we're in default vault
|
||||||
_, err := runSecret("vault", "select", "default")
|
_, err := runSecret("vault", "select", "default")
|
||||||
require.NoError(t, err, "vault select should succeed")
|
require.NoError(t, err, "vault select should succeed")
|
||||||
|
|
||||||
// Run multiple concurrent reads, as separate processes: within one
|
// Run multiple concurrent reads
|
||||||
// process the first command to read the mnemonic would unset it for
|
|
||||||
// the others
|
|
||||||
const numReaders = 5
|
const numReaders = 5
|
||||||
|
|
||||||
errCh := make(chan error, numReaders)
|
errCh := make(chan error, numReaders)
|
||||||
|
|
||||||
for i := range numReaders {
|
for i := range numReaders {
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
|
output, err := runSecretWithEnv(map[string]string{
|
||||||
cmd.Env = []string{
|
secret.EnvMnemonic: testMnemonic,
|
||||||
secret.EnvStateDir + "=" + tempDir,
|
}, "get", "database/password")
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
|
||||||
"PATH=" + os.Getenv("PATH"),
|
|
||||||
"HOME=" + os.Getenv("HOME"),
|
|
||||||
}
|
|
||||||
output, err := cmd.Output()
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
|
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
|
||||||
case strings.TrimSpace(string(output)) == "":
|
case strings.TrimSpace(output) == "":
|
||||||
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
|
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
|
||||||
default:
|
default:
|
||||||
errCh <- nil
|
errCh <- nil
|
||||||
|
|||||||
@@ -1,564 +0,0 @@
|
|||||||
//nolint:testpackage // sets the unexported fields of Instance
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// lockWait is how long a test waits for something that must happen
|
|
||||||
// once the lock is free.
|
|
||||||
lockWait = 10 * time.Second
|
|
||||||
|
|
||||||
// testPassphrase protects the passphrase unlockers the tests create.
|
|
||||||
testPassphrase = "test-passphrase"
|
|
||||||
|
|
||||||
// testInput is a file outside the state directory that commands read.
|
|
||||||
testInput = "/input"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockInBackground starts taking the state directory lock and returns a
|
|
||||||
// channel that delivers the function releasing it once it has been taken.
|
|
||||||
func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
taken := make(chan func(), 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
if assert.NoError(t, err) {
|
|
||||||
taken <- release
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return taken
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAtOnce runs one add of the secret name per value, all at once, and
|
|
||||||
// returns their errors. Each add is given mnemonic, which a forced add
|
|
||||||
// needs.
|
|
||||||
func addAtOnce(
|
|
||||||
fs afero.Fs, stateDir, name string, force bool, values []string,
|
|
||||||
mnemonic *memguard.LockedBuffer,
|
|
||||||
) []error {
|
|
||||||
errs := make(chan error, len(values))
|
|
||||||
|
|
||||||
for _, value := range values {
|
|
||||||
go func() {
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
cli.Mnemonic = mnemonic
|
|
||||||
cli.cmd = &cobra.Command{}
|
|
||||||
cli.cmd.SetIn(strings.NewReader(value))
|
|
||||||
|
|
||||||
errs <- cli.AddSecret(name, force)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]error, 0, len(values))
|
|
||||||
for range values {
|
|
||||||
results = append(results, <-errs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// numbered returns count distinct values starting with prefix.
|
|
||||||
func numbered(prefix string, count int) []string {
|
|
||||||
values := make([]string, 0, count)
|
|
||||||
for i := range count {
|
|
||||||
values = append(values, prefix+"-"+strconv.Itoa(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
|
|
||||||
// the in-memory and on the real filesystem. Without the state directory
|
|
||||||
// lock, adds of a new secret all find it absent and replace each other, and
|
|
||||||
// forced adds read the same highest version number and overwrite each
|
|
||||||
// other's version. With it they behave as if run one after another.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
const adds = 8
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
fs afero.Fs
|
|
||||||
stateDir string
|
|
||||||
}{
|
|
||||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// One add creates the secret; the others find that it exists
|
|
||||||
created := 0
|
|
||||||
|
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
|
||||||
numbered("create", adds), mnemonic) {
|
|
||||||
if err == nil {
|
|
||||||
created++
|
|
||||||
} else {
|
|
||||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, 1, created, "exactly one add creates the secret")
|
|
||||||
|
|
||||||
// Every forced add stores a version of its own
|
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
|
||||||
numbered("force", adds), mnemonic) {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(tc.fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "shared"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, adds+1, "one version per successful add")
|
|
||||||
|
|
||||||
values := make(map[string]bool, len(versions))
|
|
||||||
|
|
||||||
for _, version := range versions {
|
|
||||||
value, err := vlt.GetSecretVersion("shared", version)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
values[string(value.Bytes())] = true
|
|
||||||
value.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, values, adds+1, "every add stored its own value")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readNotifier passes reads through to Reader and closes reading at the
|
|
||||||
// first one.
|
|
||||||
type readNotifier struct {
|
|
||||||
io.Reader
|
|
||||||
|
|
||||||
reading chan struct{}
|
|
||||||
once sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *readNotifier) Read(p []byte) (int, error) {
|
|
||||||
r.once.Do(func() { close(r.reading) })
|
|
||||||
|
|
||||||
return r.Reader.Read(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in
|
|
||||||
// one process, starting encrypt once add is reading its input. Had add
|
|
||||||
// taken the state directory lock before reading, it would hold the lock
|
|
||||||
// while waiting for encrypt's output, and encrypt would wait for the lock
|
|
||||||
// to store its key: neither would finish.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
|
||||||
|
|
||||||
pipeReader, pipeWriter := io.Pipe()
|
|
||||||
// If the test gives up, this makes add's read fail, so that both
|
|
||||||
// commands return and release the lock the other tests use
|
|
||||||
t.Cleanup(func() { _ = pipeReader.Close() })
|
|
||||||
|
|
||||||
const commands = 2
|
|
||||||
|
|
||||||
input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})}
|
|
||||||
results := make(chan error, commands)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
add := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
add.cmd = &cobra.Command{}
|
|
||||||
add.cmd.SetIn(input)
|
|
||||||
|
|
||||||
results <- add.AddSecret("encrypted", false)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-input.reading
|
|
||||||
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(pipeWriter)
|
|
||||||
|
|
||||||
err := encrypt.Encrypt("key", testInput, "")
|
|
||||||
// Ends add's input, as the end of the pipe does
|
|
||||||
_ = pipeWriter.CloseWithError(err)
|
|
||||||
|
|
||||||
results <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
timeout := time.After(lockWait)
|
|
||||||
|
|
||||||
for range commands {
|
|
||||||
select {
|
|
||||||
case err := <-results:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatal("secret encrypt piped into secret add never finished")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedCommandReleasesLock checks that a command failing after it
|
|
||||||
// took the state directory lock leaves the lock free for the next command.
|
|
||||||
func TestFailedCommandReleasesLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
// Fails once it holds the lock: there is no current vault
|
|
||||||
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-lockInBackground(t, fs):
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the failed command left the state directory locked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateDirModTimes returns the modification time of every file and
|
|
||||||
// directory under the test state directory. Any change a command makes, even
|
|
||||||
// rewriting a file with the same content, changes it.
|
|
||||||
func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
modTimes := make(map[string]int64)
|
|
||||||
|
|
||||||
err := afero.Walk(fs, testStateDir,
|
|
||||||
func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
modTimes[path] = info.ModTime().UnixNano()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return modTimes
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupEveryCommand makes what each command in
|
|
||||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
|
||||||
// versions of "test/secret", the vault "other" without a long-term key, for
|
|
||||||
// vault import, and the file testInput. There is no vault "default", which
|
|
||||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
|
||||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
|
||||||
// ID.
|
|
||||||
func setupEveryCommand(
|
|
||||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
otherDir, err := other.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
|
||||||
addTestSecret(t, vlt, []byte("newer"), true)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 2)
|
|
||||||
|
|
||||||
unlockerID := ""
|
|
||||||
|
|
||||||
if withUnlocker {
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
unlockerID = unlocker.GetID()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
||||||
|
|
||||||
// Newest first
|
|
||||||
return versions[1], unlockerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitingForLock reports whether a goroutine is stopped in
|
|
||||||
// vault.LockStateDir, waiting for the in-memory filesystem's lock. The
|
|
||||||
// stack trace of such a goroutine starts with the reason it waits,
|
|
||||||
// "[sync.Mutex.Lock]", and names LockStateDir.
|
|
||||||
func waitingForLock() bool {
|
|
||||||
stacks := make([]byte, 1<<20)
|
|
||||||
stacks = stacks[:runtime.Stack(stacks, true)]
|
|
||||||
|
|
||||||
for goroutine := range bytes.SplitSeq(stacks, []byte("\n\n")) {
|
|
||||||
if bytes.Contains(goroutine, []byte("[sync.Mutex.Lock")) &&
|
|
||||||
bytes.Contains(goroutine, []byte("vault.LockStateDir(")) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
|
||||||
// while holding the state directory lock. The command must neither finish
|
|
||||||
// nor change anything before it waits for the lock, and must succeed once
|
|
||||||
// the lock is released.
|
|
||||||
func requireWaitsForLock(
|
|
||||||
t *testing.T,
|
|
||||||
withUnlocker bool,
|
|
||||||
run func(cli *Instance, olderVersion, unlockerID string) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
|
||||||
before := stateDirModTimes(t, fs)
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Released at most once, and also if the test fails while holding it,
|
|
||||||
// so that later tests can take it
|
|
||||||
release = sync.OnceFunc(release)
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
defer unlockPassphrase.Destroy()
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
cli.UnlockPassphrase = unlockPassphrase
|
|
||||||
cli.cmd = &cobra.Command{}
|
|
||||||
cli.cmd.SetIn(strings.NewReader("value"))
|
|
||||||
cli.cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
|
||||||
|
|
||||||
timeout := time.After(lockWait)
|
|
||||||
|
|
||||||
for !waitingForLock() {
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
t.Fatalf("finished while the lock was held, with error %v", err)
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatal("never waited for the lock")
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, before, stateDirModTimes(t, fs),
|
|
||||||
"changed the state directory before waiting for the lock")
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("did not finish once the lock was released")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChangingCommandsWaitForLock checks that each command that changes the
|
|
||||||
// state directory waits for its lock.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
|
||||||
func TestChangingCommandsWaitForLock(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
withUnlocker bool
|
|
||||||
run func(cli *Instance, olderVersion, unlockerID string) error
|
|
||||||
}{
|
|
||||||
{"add", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.AddSecret("added", false)
|
|
||||||
}},
|
|
||||||
{"import", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.ImportSecret(cli.cmd, "imported", testInput, false)
|
|
||||||
}},
|
|
||||||
{"generate secret", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.GenerateSecret(cli.cmd, "generated", 16, "base58", false)
|
|
||||||
}},
|
|
||||||
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.Encrypt("key", testInput, "")
|
|
||||||
}},
|
|
||||||
{"rm", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.RemoveSecret(cli.cmd, "test/secret", false)
|
|
||||||
}},
|
|
||||||
{"move", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
|
||||||
}},
|
|
||||||
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
|
||||||
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
|
||||||
}},
|
|
||||||
{"version rm", false, func(cli *Instance, olderVersion, _ string) error {
|
|
||||||
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion)
|
|
||||||
}},
|
|
||||||
{"vault create", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.CreateVault(cli.cmd, "created")
|
|
||||||
}},
|
|
||||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.SelectVault(cli.cmd, "other")
|
|
||||||
}},
|
|
||||||
{"vault import", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.VaultImport(cli.cmd, "other")
|
|
||||||
}},
|
|
||||||
{"vault rm", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.RemoveVault(cli.cmd, "other", false)
|
|
||||||
}},
|
|
||||||
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
||||||
}},
|
|
||||||
{"unlocker rm", true, func(cli *Instance, _, unlockerID string) error {
|
|
||||||
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
|
||||||
}},
|
|
||||||
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
|
||||||
return cli.UnlockerSelect(unlockerID)
|
|
||||||
}},
|
|
||||||
{"init", false, func(cli *Instance, _, _ string) error {
|
|
||||||
return cli.Init(cli.cmd)
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
requireWaitsForLock(t, tc.withUnlocker, tc.run)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
|
||||||
// key that already exists, which only reads the state directory, finishes
|
|
||||||
// while another command holds the state directory lock.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
||||||
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.Mnemonic = mnemonic
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
// Stores the key
|
|
||||||
require.NoError(t, encrypt.Encrypt("key", testInput, ""))
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Also frees a waiting encrypt if the test fails, so that it releases
|
|
||||||
// the lock the other tests use
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { done <- encrypt.Encrypt("key", testInput, "") }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("secret encrypt with an existing key waited for the lock")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEncryptStreamsUnlocked checks that secret encrypt has released the
|
|
||||||
// state directory lock by the time it writes its output. Holding it while
|
|
||||||
// streaming would stall every other changing command for as long as the
|
|
||||||
// stream lasts, and forever when the other end of the pipe is one of them.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
||||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
|
||||||
|
|
||||||
outputReader, outputWriter := io.Pipe()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
encrypt.cmd = &cobra.Command{}
|
|
||||||
encrypt.cmd.SetOut(outputWriter)
|
|
||||||
|
|
||||||
err := encrypt.Encrypt("key", testInput, "")
|
|
||||||
_ = outputWriter.CloseWithError(err)
|
|
||||||
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
// The first byte of output: encrypt is streaming now, and blocked
|
|
||||||
// writing until it is read
|
|
||||||
_, err = io.ReadFull(outputReader, make([]byte, 1))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
taken := lockInBackground(t, fs)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-taken:
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
// Let encrypt finish, so that it releases the lock, then free it
|
|
||||||
// again for the tests that follow
|
|
||||||
_, _ = io.Copy(io.Discard, outputReader)
|
|
||||||
|
|
||||||
(<-taken)()
|
|
||||||
t.Fatal("secret encrypt held the lock while streaming")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(io.Discard, outputReader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, <-done)
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
|
|
||||||
// onto itself deleted it, also when "work" was spelled two ways, and a failed
|
|
||||||
// move within "work" left "work" the current vault. "default" is the current
|
|
||||||
// vault in every case, and each case runs on its own copy of the state
|
|
||||||
// directory.
|
|
||||||
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
||||||
|
|
||||||
const (
|
|
||||||
ontoItself = "secret 'x' cannot be moved onto itself"
|
|
||||||
workX = "work:x"
|
|
||||||
)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
source, dest string
|
|
||||||
force bool
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"mv x x", "x", "x", false, ontoItself},
|
|
||||||
{"mv --force x x", "x", "x", true, ontoItself},
|
|
||||||
{"mv --force work:x work:", workX, "work:", true, ontoItself},
|
|
||||||
// An empty destination name defaults to the source name.
|
|
||||||
{`mv --force work:x ""`, workX, "", true, ontoItself},
|
|
||||||
// "work" is a vault name, so the destination is work:x.
|
|
||||||
{"mv --force work:x work", workX, "work", true, ontoItself},
|
|
||||||
{
|
|
||||||
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
|
||||||
"secret 'nosuch' not found",
|
|
||||||
},
|
|
||||||
// Only an existing vault is used.
|
|
||||||
{
|
|
||||||
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
|
||||||
"vault 'nosuch' does not exist",
|
|
||||||
},
|
|
||||||
// Each of these spells "work" a second way. The spelling is not a
|
|
||||||
// valid vault name, so the move is not taken for a move between two
|
|
||||||
// vaults, which would delete the destination, here the source.
|
|
||||||
{
|
|
||||||
"mv --force work:x work/:x", workX, "work/:x", true,
|
|
||||||
vault.ValidateVaultName("work/").Error(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work/:x work:", "work/:x", "work:", true,
|
|
||||||
vault.ValidateVaultName("work/").Error(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work:x ./work:x", workX, "./work:x", true,
|
|
||||||
vault.ValidateVaultName("./work").Error(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
||||||
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
require.EqualError(t, err, tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
|
|
||||||
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
|
|
||||||
// leaves "default" the current vault.
|
|
||||||
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
after := snapshotStateDir(t, fs)
|
|
||||||
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
|
|
||||||
|
|
||||||
require.Equal(t, "default", after[testStateDir+"/currentvault"])
|
|
||||||
require.Contains(t, after, workSecrets+"y/")
|
|
||||||
require.NotContains(t, after, workSecrets+"x/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive
|
|
||||||
// filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo`
|
|
||||||
// removed the destination, which was the source. Symbolic links on the real
|
|
||||||
// filesystem give one secret two names here: in "default", "y" is a link to
|
|
||||||
// the secret "x", and the secrets.d of "other" is a link to that of
|
|
||||||
// "default", so other:x is default:x. Each move must be rejected and leave
|
|
||||||
// the secret and the links as they were.
|
|
||||||
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const isSame = "is the same secret on this filesystem"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
source, dest string
|
|
||||||
force bool
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"mv --force y x", "y", "x", true,
|
|
||||||
"secret 'y' cannot be moved onto itself: 'x' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force x y", "x", "y", true,
|
|
||||||
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv x y", "x", "y", false,
|
|
||||||
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force default:x other:x", "default:x", "other:x", true,
|
|
||||||
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
|
||||||
isSame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv default:x other", "default:x", "other", false,
|
|
||||||
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
|
||||||
isSame,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
|
||||||
|
|
||||||
// "default" is created last, so it is the current vault.
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d")
|
|
||||||
otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d")
|
|
||||||
link := filepath.Join(defaultSecrets, "y")
|
|
||||||
|
|
||||||
require.NoError(t, os.Symlink("x", link))
|
|
||||||
require.NoError(t, os.Remove(otherSecrets))
|
|
||||||
require.NoError(t, os.Symlink(defaultSecrets, otherSecrets))
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, []byte("value"), value.Bytes())
|
|
||||||
|
|
||||||
target, err := os.Readlink(link)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "x", target)
|
|
||||||
|
|
||||||
target, err = os.Readlink(otherSecrets)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, defaultSecrets, target)
|
|
||||||
|
|
||||||
require.EqualError(t, moveErr, tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo"
|
|
||||||
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
|
|
||||||
// with "Foo".
|
|
||||||
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo"))
|
|
||||||
if err == nil {
|
|
||||||
t.Skip("the temporary directory is on a case-insensitive filesystem")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret("foo")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, []byte("upper"), value.Bytes())
|
|
||||||
|
|
||||||
_, err = vlt.GetSecret("Foo")
|
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
@@ -33,58 +31,31 @@ const (
|
|||||||
missingFile = "/no/such/file"
|
missingFile = "/no/such/file"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
|
|
||||||
// Creating a passphrase unlocker is slow by design, so the vaults are made
|
|
||||||
// once, by the first test that needs them.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
|
|
||||||
var (
|
|
||||||
twoVaultsOnce sync.Once
|
|
||||||
twoVaults map[string]string
|
|
||||||
)
|
|
||||||
|
|
||||||
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
||||||
// and "default", the current one. Each holds the secret "x" and a
|
// and "default", the current one. Each holds the secret "x" and a
|
||||||
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
// 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
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||||
func newTwoVaultFs(t *testing.T) afero.Fs {
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
twoVaultsOnce.Do(func() {
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
for _, name := range []string{"work", "default"} {
|
fs := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
for _, name := range []string{"work", "default"} {
|
||||||
require.NoError(t, err)
|
vlt, err := vault.CreateVault(fs, testStateDir, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
twoVaults = snapshotStateDir(t, fs)
|
_, err = vlt.CreatePassphraseUnlocker(
|
||||||
})
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
|
return fs
|
||||||
|
|
||||||
return newFsFromSnapshot(t, twoVaults)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotStateDir maps every file under the state directory to its
|
// snapshotStateDir maps every file under the state directory to its
|
||||||
@@ -148,13 +119,13 @@ func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requireRejectedAndUnchanged runs a command on a copy of the state
|
// requireRejectedAndUnchanged runs a command on a copy of the state
|
||||||
// directory recorded in before. It requires an error with exactly the
|
// directory recorded in before. It requires exactly the error
|
||||||
// message of want, so that a later check rejecting the argument does not
|
// vault.ValidateSecretName gives for the rejected name, so that a later
|
||||||
// count, and everything under the state directory as it was: the error
|
// check rejecting the name does not count, and everything under the state
|
||||||
// alone proves nothing, since it could come after the vault had already
|
// directory as it was: the error alone proves nothing, since it could come
|
||||||
// been deleted.
|
// after the vault had already been deleted.
|
||||||
func requireRejectedAndUnchanged(
|
func requireRejectedAndUnchanged(
|
||||||
t *testing.T, before map[string]string, want error,
|
t *testing.T, before map[string]string, rejected string,
|
||||||
run func(c *cli.Instance) error,
|
run func(c *cli.Instance) error,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -164,7 +135,8 @@ func requireRejectedAndUnchanged(
|
|||||||
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
||||||
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||||
require.EqualError(t, err, want.Error())
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
||||||
|
require.EqualError(t, err, vault.ValidateSecretName(rejected).Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
||||||
@@ -173,7 +145,7 @@ func requireRejectedAndUnchanged(
|
|||||||
// Moves and imports use --force, so that only the name check stands in
|
// Moves and imports use --force, so that only the name check stands in
|
||||||
// the way.
|
// the way.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // the cases share cmd
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
||||||
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
||||||
// Creating a passphrase unlocker is slow by design, so the vaults are
|
// Creating a passphrase unlocker is slow by design, so the vaults are
|
||||||
// created once and each case runs on its own copy of them.
|
// created once and each case runs on its own copy of them.
|
||||||
@@ -181,7 +153,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
vaultDir := testStateDir + "/vaults.d/default"
|
vaultDir := testStateDir + "/vaults.d/default"
|
||||||
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
||||||
require.Contains(t, before, vaultDir+"/current-unlocker")
|
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
@@ -257,159 +229,17 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
|
requireRejectedAndUnchanged(t, before, tt.rejected, tt.run)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/67, where
|
|
||||||
// `secret version rm x ../../..` deleted the whole vault,
|
|
||||||
// `secret version rm x ..` the secret x, and `secret version rm x .` or
|
|
||||||
// `secret version rm x ""` every version of x. A version argument is
|
|
||||||
// accepted only if it is one of the versions `secret version list` lists.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
commands := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *cli.Instance, version string) error
|
|
||||||
}{
|
|
||||||
{"version rm x", func(c *cli.Instance, version string) error {
|
|
||||||
return c.RemoveVersion(cmd, "x", version)
|
|
||||||
}},
|
|
||||||
{"version promote x", func(c *cli.Instance, version string) error {
|
|
||||||
return c.PromoteVersion(cmd, "x", version)
|
|
||||||
}},
|
|
||||||
{"get x --version", func(c *cli.Instance, version string) error {
|
|
||||||
return c.GetSecretWithVersion(cmd, "x", version)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range commands {
|
|
||||||
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
|
||||||
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
|
||||||
want := fmt.Errorf("version '%s' %w '%s'",
|
|
||||||
version, vault.ErrVersionNotFound, "x")
|
|
||||||
requireRejectedAndUnchanged(t, before, want,
|
|
||||||
func(c *cli.Instance) error { return tt.run(c, version) })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
|
||||||
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
|
||||||
// state directory itself, and `secret vault select ..` made it the current
|
|
||||||
// vault. Each command that takes a vault name must reject an invalid one
|
|
||||||
// before building a path from it. The instance is given the mnemonic and
|
|
||||||
// the passphrase, and moves and removals use --force, so that only the name
|
|
||||||
// check stands in the way.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // the cases share cmd
|
|
||||||
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
// Each command is a format with %q where the vault name goes.
|
|
||||||
commands := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *cli.Instance, name string) error
|
|
||||||
}{
|
|
||||||
{"vault create %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.CreateVault(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault import %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.VaultImport(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault select %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.SelectVault(cmd, name)
|
|
||||||
}},
|
|
||||||
{"vault remove --force %q", func(c *cli.Instance, name string) error {
|
|
||||||
return c.RemoveVault(cmd, name, true)
|
|
||||||
}},
|
|
||||||
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
|
|
||||||
return c.MoveSecret(cmd, name+":x", "work:x", true)
|
|
||||||
}},
|
|
||||||
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
|
|
||||||
return c.MoveSecret(cmd, "default:x", name+":x", true)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range commands {
|
|
||||||
for _, name := range []string{"", ".", "..", "a/b"} {
|
|
||||||
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
|
||||||
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
c.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
return tt.run(c, name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
|
||||||
// with a version that is not the current one removes that version and
|
|
||||||
// changes nothing else.
|
|
||||||
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// A second version of "x" becomes the current one.
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 2)
|
|
||||||
|
|
||||||
// ListVersions lists the newest version first.
|
|
||||||
oldDir := secretDir + "/versions/" + versions[1] + "/"
|
|
||||||
before := snapshotStateDir(t, fs)
|
|
||||||
require.Contains(t, before, oldDir)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1])
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Expected: the state as before without everything under oldDir.
|
|
||||||
want := map[string]string{}
|
|
||||||
|
|
||||||
for path, content := range before {
|
|
||||||
if !strings.HasPrefix(path, oldDir) {
|
|
||||||
want[path] = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
||||||
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
||||||
// in the current vault and changes nothing else.
|
// in the current vault and changes nothing else.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
||||||
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
fs := newFsFromSnapshot(t, before)
|
fs := newFsFromSnapshot(t, before)
|
||||||
|
|
||||||
|
|||||||
+2
-23
@@ -46,30 +46,9 @@ func newRootCmd() *cobra.Command {
|
|||||||
Short: "A simple secrets manager",
|
Short: "A simple secrets manager",
|
||||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||||
`information securely.`,
|
`information securely.`,
|
||||||
// Cobra prints the error a command returns; Entry does not.
|
// Ensure usage is shown after errors
|
||||||
|
SilenceUsage: false,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
// Usage belongs only to a command called wrongly. Cobra has
|
|
||||||
// checked its arguments and flag values before this runs, but
|
|
||||||
// checks required flags (ValidateRequiredFlags) and flag groups
|
|
||||||
// (ValidateFlagGroups) only after it, so both are checked here
|
|
||||||
// to keep usage for them. An error after that comes from running
|
|
||||||
// the command, and usage would only bury it. A subcommand that
|
|
||||||
// sets its own PersistentPreRun replaces this one.
|
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
err := cmd.ValidateRequiredFlags()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cmd.ValidateFlagGroups()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.SilenceUsage = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Adding subcommands to root command")
|
secret.Debug("Adding subcommands to root command")
|
||||||
|
|||||||
+77
-218
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -41,7 +40,6 @@ var (
|
|||||||
errVaultDoesNotExist = errors.New("does not exist")
|
errVaultDoesNotExist = errors.New("does not exist")
|
||||||
errCrossVaultSourceUnqualified = errors.New(
|
errCrossVaultSourceUnqualified = errors.New(
|
||||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
"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
|
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||||
@@ -81,9 +79,6 @@ func newAddCmd() *cobra.Command {
|
|||||||
|
|
||||||
cli.cmd = cmd // Set the command for stdin access
|
cli.cmd = cmd // Set the command for stdin access
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
secret.Debug("Created CLI instance, calling AddSecret")
|
secret.Debug("Created CLI instance, calling AddSecret")
|
||||||
|
|
||||||
return cli.AddSecret(args[0], force)
|
return cli.AddSecret(args[0], force)
|
||||||
@@ -114,15 +109,6 @@ func newGetCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
// 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)
|
return cli.GetSecretWithVersion(cmd, args[0], version)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -180,9 +166,6 @@ func newImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -257,9 +240,6 @@ The source secret is deleted after successful copy.`,
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.MoveSecret(cmd, args[0], args[1], force)
|
return cli.MoveSecret(cmd, args[0], args[1], force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -366,8 +346,6 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||||
|
|
||||||
// Read secret value directly into protected buffers
|
// Read secret value directly into protected buffers
|
||||||
@@ -399,15 +377,6 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
valueBuffer := combineBuffers(buffers, totalSize)
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Locked only now that stdin has been read: in `secret encrypt key |
|
|
||||||
// secret add name`, holding the lock while reading would leave each
|
|
||||||
// command waiting for the other.
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Add the secret to the vault
|
// Add the secret to the vault
|
||||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
||||||
"value_length", valueBuffer.Size(), "force", force)
|
"value_length", valueBuffer.Size(), "force", force)
|
||||||
@@ -424,38 +393,21 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret retrieves and prints the current version of a secret
|
// GetSecret retrieves and prints a secret from the current vault
|
||||||
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||||
secret.Debug("GetSecret called", "secretName", secretName)
|
return cli.GetSecretWithVersion(cmd, secretName, "")
|
||||||
|
|
||||||
// Get current vault
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
// Write the value straight from locked memory, with no trailing newline
|
|
||||||
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretWithVersion retrieves and prints a specific version of a secret.
|
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||||
// The version must be one of the secret's versions.
|
|
||||||
func (cli *Instance) GetSecretWithVersion(
|
func (cli *Instance) GetSecretWithVersion(
|
||||||
cmd *cobra.Command, secretName string, version string,
|
cmd *cobra.Command, secretName string, version string,
|
||||||
) error {
|
) error {
|
||||||
secret.Debug("GetSecretWithVersion called",
|
secret.Debug("GetSecretWithVersion called",
|
||||||
"secretName", secretName, "version", version)
|
"secretName", secretName, "version", version)
|
||||||
|
|
||||||
|
// Store the command for output
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -464,24 +416,35 @@ func (cli *Instance) GetSecretWithVersion(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Get the secret value
|
// Get the secret value
|
||||||
value, err := vlt.GetSecretVersion(secretName, version)
|
var value []byte
|
||||||
|
if version == "" {
|
||||||
|
value, err = vlt.GetSecret(secretName)
|
||||||
|
} else {
|
||||||
|
value, err = vlt.GetSecretVersion(secretName, version)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get secret", "error", err)
|
secret.Debug("Failed to get secret", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
secret.Debug("Got secret value", "valueLength", value.Size())
|
secret.Debug("Got secret value", "valueLength", len(value))
|
||||||
|
|
||||||
// Write the value straight from locked memory, with no trailing newline
|
// Print the secret value to stdout
|
||||||
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
_, _ = cli.Print(string(value))
|
||||||
|
|
||||||
secret.Debug("Printed value to stdout")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +614,6 @@ func (cli *Instance) ImportSecret(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Read secret value from the source file into protected buffers
|
// Read secret value from the source file into protected buffers
|
||||||
file, err := cli.fs.Open(sourceFile)
|
file, err := cli.fs.Open(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -679,14 +640,6 @@ func (cli *Instance) ImportSecret(
|
|||||||
valueBuffer := combineBuffers(buffers, totalSize)
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Locked only now that the file has been read, as in AddSecret: the
|
|
||||||
// file may be a pipe written by another secret command.
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Store the secret in the vault
|
// Store the secret in the vault
|
||||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -706,12 +659,6 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -746,7 +693,7 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the secret directory
|
// Remove the secret directory
|
||||||
err = secret.RemoveDirAtomic(cli.fs, secretDir)
|
err = cli.fs.RemoveAll(secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove secret: %w", err)
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
}
|
}
|
||||||
@@ -757,24 +704,9 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveSecret moves or renames a secret (within or across vaults), holding
|
// MoveSecret moves or renames a secret (within or across vaults)
|
||||||
// the state directory lock.
|
|
||||||
func (cli *Instance) MoveSecret(
|
func (cli *Instance) MoveSecret(
|
||||||
cmd *cobra.Command, source, dest string, force bool,
|
cmd *cobra.Command, source, dest string, force bool,
|
||||||
) error {
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.moveSecret(cmd, source, dest, force)
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveSecret does the work of MoveSecret. The caller holds the state
|
|
||||||
// directory lock.
|
|
||||||
func (cli *Instance) moveSecret(
|
|
||||||
cmd *cobra.Command, source, dest string, force bool,
|
|
||||||
) error {
|
) error {
|
||||||
// Parse source and destination
|
// Parse source and destination
|
||||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||||
@@ -791,8 +723,8 @@ func (cli *Instance) moveSecret(
|
|||||||
// with a new name
|
// with a new name
|
||||||
if srcQualified && !destQualified {
|
if srcQualified && !destQualified {
|
||||||
// Check if dest is actually a vault name
|
// Check if dest is actually a vault name
|
||||||
_, err := cli.existingVault(dest)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
if err == nil {
|
if err == nil && slices.Contains(vaults, dest) {
|
||||||
// dest is a vault name, use source secret name
|
// dest is a vault name, use source secret name
|
||||||
destVaultName = dest
|
destVaultName = dest
|
||||||
destSecretName = srcSecretName
|
destSecretName = srcSecretName
|
||||||
@@ -808,8 +740,8 @@ func (cli *Instance) moveSecret(
|
|||||||
destSecretName = srcSecretName
|
destSecretName = srcSecretName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check both names, for every form of the move, before building any path
|
// Check both names, for every form of the move, before selecting a vault
|
||||||
// from them.
|
// below, so that a rejected move leaves the current vault as it was.
|
||||||
err := vault.ValidateSecretName(srcSecretName)
|
err := vault.ValidateSecretName(srcSecretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -820,72 +752,38 @@ func (cli *Instance) moveSecret(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neither name is qualified: a rename within the current vault.
|
// If neither is qualified, this is a simple within-vault rename
|
||||||
if !srcQualified {
|
if !srcQualified && !destQualified {
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same vault? Use simple rename if possible (optimization)
|
||||||
|
if srcVaultName == destVaultName {
|
||||||
|
// Select the vault and do a simple move
|
||||||
|
err = vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.moveSecretWithinVault(
|
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||||
cmd, vlt, srcSecretName, destSecretName, force)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both vault names must be valid and name existing vaults exactly, so
|
|
||||||
// that two spellings of one vault, such as "work" and "work/", are never
|
|
||||||
// taken for two vaults. A named vault does not become the current vault.
|
|
||||||
srcVault, err := cli.existingVault(srcVaultName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
destVault, err := cli.existingVault(destVaultName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if srcVaultName == destVaultName {
|
|
||||||
return cli.moveSecretWithinVault(
|
|
||||||
cmd, srcVault, srcSecretName, destSecretName, force)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-vault move
|
||||||
return cli.moveSecretCrossVault(
|
return cli.moveSecretCrossVault(
|
||||||
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// existingVault returns the vault with the given name, or an error if the
|
// moveSecretWithinVault handles rename within the current vault. Its caller,
|
||||||
// name is not a valid vault name or there is no such vault. Unlike
|
|
||||||
// vault.SelectVault, it leaves the current vault as it is.
|
|
||||||
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
|
||||||
err := vault.ValidateVaultName(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(vaults, name) {
|
|
||||||
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
|
|
||||||
// MoveSecret, has already checked both secret names.
|
// MoveSecret, has already checked both secret names.
|
||||||
func (cli *Instance) moveSecretWithinVault(
|
func (cli *Instance) moveSecretWithinVault(
|
||||||
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
|
cmd *cobra.Command, source, dest string, force bool,
|
||||||
) error {
|
) error {
|
||||||
// With --force the destination is removed before the source is renamed
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
// onto it, which would delete the secret.
|
if err != nil {
|
||||||
if source == dest {
|
return err
|
||||||
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -905,18 +803,6 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||||
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
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)
|
exists, err = afero.DirExists(cli.fs, destDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||||
@@ -927,7 +813,7 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
err = cli.fs.RemoveAll(destDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||||
}
|
}
|
||||||
@@ -943,84 +829,57 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameDirectory reports whether the existing directory dir and the path
|
// moveSecretCrossVault handles moving between different vaults. Its caller,
|
||||||
// other are one directory under two names, as secrets.d/Foo and
|
// MoveSecret, has already checked both secret names.
|
||||||
// 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(
|
func (cli *Instance) moveSecretCrossVault(
|
||||||
cmd *cobra.Command,
|
cmd *cobra.Command,
|
||||||
srcVault *vault.Vault, srcSecretName string,
|
srcVaultName, srcSecretName,
|
||||||
destVault *vault.Vault, destSecretName string,
|
destVaultName, destSecretName string,
|
||||||
force bool,
|
force bool,
|
||||||
) error {
|
) error {
|
||||||
|
// Get source vault
|
||||||
|
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||||
|
|
||||||
srcVaultDir, err := srcVault.GetDirectory()
|
srcVaultDir, err := srcVault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
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
|
// Verify source secret exists
|
||||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
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 {
|
if err != nil || !exists {
|
||||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||||
srcSecretName, errSecretNotFound, srcVault.Name)
|
srcSecretName, errSecretNotFound, srcVaultName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The source is removed after the copy, so a destination that is the
|
// Get destination vault
|
||||||
// source under another name would be lost with it.
|
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
||||||
|
|
||||||
destVaultDir, err := destVault.GetDirectory()
|
destVaultDir, err := destVault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
// Verify destination vault exists
|
||||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||||
|
if err != nil || !exists {
|
||||||
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
return fmt.Errorf("destination vault '%s' %w",
|
||||||
if err != nil {
|
destVaultName, errVaultDoesNotExist)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if same {
|
|
||||||
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
|
|
||||||
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
|
||||||
destVault.Name, destSecretName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copying needs the long-term keys of both vaults
|
|
||||||
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||||
_, err = destVault.GetOrDeriveLongTermKey()
|
_, err = destVault.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
|
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count versions for user feedback
|
// Count versions for user feedback
|
||||||
@@ -1035,18 +894,18 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete source secret
|
// Delete source secret
|
||||||
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
|
err = cli.fs.RemoveAll(srcSecretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Copy succeeded but delete failed - warn but don't fail
|
// 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("Warning: copied secret but failed to remove source: %v\n", err)
|
||||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -70,8 +71,11 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
@@ -139,10 +143,7 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue,
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original (without newline)")
|
"Retrieved secret should match original (without newline)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,16 +193,12 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretVariousSizes(t *testing.T) {
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -261,7 +258,7 @@ func TestAddSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestImportSecretVariousSizes(t *testing.T) {
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -321,7 +318,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Test various sizes that should trigger buffer growth
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
@@ -378,19 +375,16 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue,
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original exactly")
|
"Retrieved secret should match original exactly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
// Create a custom reader that simulates slow streaming input
|
// Create a custom reader that simulates slow streaming input
|
||||||
@@ -422,11 +416,7 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret("streaming-test")
|
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
||||||
"Retrieved secret should match original")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// slowReader simulates a reader that returns data in small chunks
|
// slowReader simulates a reader that returns data in small chunks
|
||||||
|
|||||||
+49
-149
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,7 @@ var (
|
|||||||
"is already added as an unlocker")
|
"is already added as an unlocker")
|
||||||
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||||
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
||||||
|
errUnlockerExists = errors.New("unlocker already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
// UnlockerInfo represents unlocker information for display
|
// UnlockerInfo represents unlocker information for display
|
||||||
@@ -229,9 +231,6 @@ func newUnlockerAddCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
unlockerType := args[0]
|
unlockerType := args[0]
|
||||||
|
|
||||||
// Validate unlocker type
|
// Validate unlocker type
|
||||||
@@ -350,10 +349,6 @@ func unlockerIDFromDir(
|
|||||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||||
// to be skipped rather than reported under a synthesized ID.
|
// 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(
|
func findUnlockerIDByMetadata(
|
||||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||||
includeSecureEnclave bool,
|
includeSecureEnclave bool,
|
||||||
@@ -376,6 +371,9 @@ func findUnlockerIDByMetadata(
|
|||||||
// Check if this is the right unlocker by comparing metadata
|
// Check if this is the right unlocker by comparing metadata
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
secret.Warn("Could not read unlocker metadata file",
|
||||||
|
"path", metadataPath, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +381,9 @@ func findUnlockerIDByMetadata(
|
|||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
secret.Warn("Could not parse unlocker metadata file",
|
||||||
|
"path", metadataPath, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,12 +534,6 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|||||||
|
|
||||||
// UnlockersAdd adds a new unlocker
|
// UnlockersAdd adds a new unlocker
|
||||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
switch unlockerType {
|
switch unlockerType {
|
||||||
case unlockerTypePassphrase:
|
case unlockerTypePassphrase:
|
||||||
return cli.addPassphraseUnlocker(cmd)
|
return cli.addPassphraseUnlocker(cmd)
|
||||||
@@ -582,19 +577,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||||
// The CreatePassphraseUnlocker method will handle getting the
|
// The CreatePassphraseUnlocker method will handle getting the
|
||||||
// long-term key
|
// long-term key
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// The new unlocker gets the passphrase from the environment, which also
|
// Check if passphrase is set in environment variable
|
||||||
// unlocks the current passphrase unlocker, else the one entered here
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
passphraseBuffer := cli.UnlockPassphrase
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
if passphraseBuffer == nil {
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
|
} else {
|
||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
}
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -603,8 +598,8 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
// CreatePassphraseUnlocker has already made it the current unlocker
|
// Auto-select the newly created unlocker
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -615,8 +610,7 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
|||||||
return errKeychainMacOSOnly
|
return errKeychainMacOSOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -646,8 +640,7 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
|||||||
return errSecureEnclaveMacOSOnly
|
return errSecureEnclaveMacOSOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
||||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -689,8 +682,7 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
|
// Resolve the GPG key ID to its fingerprint
|
||||||
// and the new unlocker's metadata both use this result
|
|
||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
@@ -699,20 +691,12 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
// Check if this GPG key is already added
|
// Check if this GPG key is already added
|
||||||
expectedID := "pgp-" + fingerprint
|
expectedID := "pgp-" + fingerprint
|
||||||
|
|
||||||
exists, err := cli.checkUnlockerExists(vlt, expectedID)
|
err = cli.checkUnlockerExists(vlt, expectedID)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||||
}
|
}
|
||||||
|
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||||
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -726,23 +710,9 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersRemove removes an unlocker, holding the state directory lock
|
// UnlockersRemove removes an unlocker with safety checks
|
||||||
// while removeUnlocker runs
|
|
||||||
func (cli *Instance) UnlockersRemove(
|
func (cli *Instance) UnlockersRemove(
|
||||||
unlockerID string, force bool, cmd *cobra.Command,
|
unlockerID string, force bool, cmd *cobra.Command,
|
||||||
) error {
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.removeUnlocker(unlockerID, force, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeUnlocker removes an unlocker with safety checks
|
|
||||||
func (cli *Instance) removeUnlocker(
|
|
||||||
unlockerID string, force bool, cmd *cobra.Command,
|
|
||||||
) error {
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
@@ -750,43 +720,14 @@ func (cli *Instance) removeUnlocker(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get list of unlockers. It leaves out a directory whose metadata file
|
// Get list of unlockers
|
||||||
// is missing or cannot be checked for, read or parsed.
|
|
||||||
unlockers, err := vlt.ListUnlockers()
|
unlockers, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list unlockers: %w", err)
|
return fmt.Errorf("failed to list unlockers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
// Check if we're removing the last unlocker
|
// Check if we're removing the last unlocker
|
||||||
removingLast := false
|
|
||||||
|
|
||||||
if len(unlockers) == 1 {
|
if len(unlockers) == 1 {
|
||||||
lastID, err := findUnlockerIDByMetadata(
|
|
||||||
cli.fs, unlockersDir, unlockers[0], true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
removingLast = lastID == unlockerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// unlockerID may instead name a directory left out of the list. If its
|
|
||||||
// metadata file is missing or corrupt it is not a working unlocker, so
|
|
||||||
// removing it never removes the last one. If the file cannot be checked
|
|
||||||
// for or read, the unlocker may be the only working one, so removing it
|
|
||||||
// counts as removing the last unlocker.
|
|
||||||
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
|
|
||||||
removingLast = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if removingLast {
|
|
||||||
// Check if vault has secrets
|
// Check if vault has secrets
|
||||||
numSecrets, err := vlt.NumSecrets()
|
numSecrets, err := vlt.NumSecrets()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -820,28 +761,8 @@ func (cli *Instance) removeUnlocker(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataUnreadable reports whether checking for or reading the metadata
|
|
||||||
// file in the unlocker directory unlockerDir fails. A missing file is not
|
|
||||||
// a failure.
|
|
||||||
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
exists, err := afero.Exists(fs, metadataPath)
|
|
||||||
if err == nil && exists {
|
|
||||||
_, err = afero.ReadFile(fs, metadataPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlockerSelect selects an unlocker as current
|
// UnlockerSelect selects an unlocker as current
|
||||||
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -851,65 +772,44 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|||||||
return vlt.SelectUnlocker(unlockerID)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkUnlockerExists reports whether the vault already has an unlocker
|
// checkUnlockerExists checks if an unlocker with the given ID exists
|
||||||
// with the given ID. It returns an error, and no answer, when unlockers.d
|
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
|
||||||
// or an unlocker's metadata file cannot be read; the caller must then not
|
// Get the list of unlockers and check if any match the ID
|
||||||
// create the unlocker. It reads unlockers.d itself because
|
unlockers, err := vlt.ListUnlockers()
|
||||||
// vault.ListUnlockers skips an unlocker it cannot read, which suits
|
if err != nil {
|
||||||
// `unlocker list` but not this check: the skipped unlocker may be the
|
secret.Warn("Could not list unlockers during duplicate check", "error", err)
|
||||||
// duplicate. A directory whose metadata file is missing or corrupt is not
|
|
||||||
// a working unlocker and is passed over.
|
return nil // If we can't list unlockers, assume it doesn't exist
|
||||||
func (cli *Instance) checkUnlockerExists(
|
}
|
||||||
vlt *vault.Vault, unlockerID string,
|
|
||||||
) (bool, error) {
|
// Get vault directory to construct unlocker instances
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to get vault directory: %w", err)
|
secret.Warn("Could not get vault directory during duplicate check",
|
||||||
|
"error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check each unlocker's ID
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
entries, err := afero.ReadDir(cli.fs, unlockersDir)
|
for _, metadata := range unlockers {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
// Construct the unlocker matching this metadata to get its ID
|
||||||
return false, nil
|
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return false, fmt.Errorf(
|
secret.Warn(
|
||||||
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
|
"Could not read unlockers directory during duplicate check, "+
|
||||||
)
|
"skipping unlocker",
|
||||||
}
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
var metadata secret.UnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
|
if id != "" && id == unlockerID {
|
||||||
return true, nil
|
return errUnlockerExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
|
||||||
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
|
||||||
|
|
||||||
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
|
|
||||||
const (
|
|
||||||
addTestSecretName = "api-key"
|
|
||||||
addTestSecretValue = "value"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
|
|
||||||
// with a passphrase unlocker, getting the vault's long-term key from the
|
|
||||||
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
|
|
||||||
// then reads a secret with neither the mnemonic nor the passphrase given, so
|
|
||||||
// through the new unlocker, which the add selects.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlocker(t *testing.T) {
|
|
||||||
newTestGPGKey(t)
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
// mnemonic is the mnemonic given while the unlocker is added, or nil.
|
|
||||||
mnemonic *memguard.LockedBuffer
|
|
||||||
}{
|
|
||||||
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
|
|
||||||
{"long-term key from the current unlocker", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret(addTestSecretName,
|
|
||||||
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(
|
|
||||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
instance.Mnemonic = test.mnemonic
|
|
||||||
instance.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
||||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
|
||||||
|
|
||||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
|
||||||
|
|
||||||
current, err := reopened.GetCurrentUnlocker()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, unlockerTypePGP, current.GetType())
|
|
||||||
|
|
||||||
value, err := reopened.GetSecret(addTestSecretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, addTestSecretValue, value.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
|
||||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
|
||||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
|
||||||
// lookup moved after anything is written would also come after getting the
|
|
||||||
// vault's long-term key, which fails first here: this vault's unlockers hold
|
|
||||||
// no keys.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
|
||||||
newTestGPGKey(t)
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
instance, cmd := newTestInstance(base)
|
|
||||||
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
|
|
||||||
|
|
||||||
err := instance.addPGPUnlocker(cmd)
|
|
||||||
|
|
||||||
require.ErrorContains(t, err, "failed to resolve GPG key fingerprint")
|
|
||||||
assertDirEntries(t, base,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
|
||||||
listTestUnlockerDirOne)
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Corrupt Unlocker Tests
|
|
||||||
//
|
|
||||||
// `secret unlocker select` and `secret unlocker remove` find an unlocker
|
|
||||||
// by its ID. These tests give the first unlocker, which sorts before the
|
|
||||||
// one the commands act on, metadata that is not JSON, and check that the
|
|
||||||
// commands step past it, and that it can itself be removed by its
|
|
||||||
// directory name, which `secret unlocker list` names in its warning. A
|
|
||||||
// last test checks that an unlocker whose metadata file cannot be read is
|
|
||||||
// removed by its directory name only as the last unlocker is.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
|
||||||
// metadata of the first unlocker replaced by text that is not JSON.
|
|
||||||
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := newListTestVault(t, 2)
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirOne, listTestMetadataFileName),
|
|
||||||
[]byte("not json"), listTestFilePerm))
|
|
||||||
|
|
||||||
return fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
|
|
||||||
// can be selected, and that the corrupt one, having no type to be used as,
|
|
||||||
// cannot be selected by its directory name.
|
|
||||||
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newCorruptUnlockerVault(t)
|
|
||||||
instance, _ := newTestInstance(fs)
|
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
|
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, listTestUnlockerDirTwo, string(current))
|
|
||||||
|
|
||||||
err = instance.UnlockerSelect(listTestUnlockerDirOne)
|
|
||||||
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
|
|
||||||
// can be removed, unless the vault holds secrets: the corrupt unlocker
|
|
||||||
// cannot unlock the vault, so the second is its last. The corrupt one can
|
|
||||||
// be removed by its directory name without --force even then.
|
|
||||||
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
unlockerID string
|
|
||||||
withSecret bool
|
|
||||||
wantErr error
|
|
||||||
wantEntries []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "the other unlocker",
|
|
||||||
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
|
||||||
wantEntries: []string{listTestUnlockerDirOne},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "the other unlocker, the last one, with secrets",
|
|
||||||
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
|
||||||
withSecret: true,
|
|
||||||
wantErr: errLastUnlocker,
|
|
||||||
wantEntries: []string{
|
|
||||||
listTestUnlockerDirOne, listTestUnlockerDirTwo,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "the corrupt unlocker by its directory name",
|
|
||||||
unlockerID: listTestUnlockerDirOne,
|
|
||||||
withSecret: true,
|
|
||||||
wantEntries: []string{listTestUnlockerDirTwo},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newCorruptUnlockerVault(t)
|
|
||||||
if tt.withSecret {
|
|
||||||
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
|
|
||||||
}
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
|
|
||||||
err := instance.UnlockersRemove(tt.unlockerID, false, cmd)
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
|
|
||||||
assertDirEntries(t, fs,
|
|
||||||
filepath.Join(testVaultDir(listTestVaultName),
|
|
||||||
listTestUnlockersDirName),
|
|
||||||
tt.wantEntries...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockerRemoveWithUnreadableMetadata asserts that removing the only
|
|
||||||
// unlocker of a vault with secrets by its directory name, when its
|
|
||||||
// metadata file cannot be checked for or read, is refused without --force:
|
|
||||||
// listing leaves it out, but it may still be the vault's only working
|
|
||||||
// unlocker. With --force it is removed. The state directory lock refuses
|
|
||||||
// the failing filesystem, so the test calls removeUnlocker, which
|
|
||||||
// UnlockersRemove runs once it holds the lock.
|
|
||||||
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
||||||
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
|
|
||||||
listTestMetadataFileName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wrap func(base afero.Fs) afero.Fs
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "checking for the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reading the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(tt.wrap(base))
|
|
||||||
|
|
||||||
err := instance.removeUnlocker(listTestUnlockerDirOne, false, cmd)
|
|
||||||
require.ErrorIs(t, err, errLastUnlocker)
|
|
||||||
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
instance.removeUnlocker(listTestUnlockerDirOne, true, cmd))
|
|
||||||
assertDirEntries(t, base, unlockersDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
// 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
|
// cannot be read while the listing is being rendered:
|
||||||
// being rendered:
|
|
||||||
//
|
//
|
||||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||||
// still listed, with its real ID and its current-unlocker marker,
|
// still listed, with its real ID and its current-unlocker marker,
|
||||||
// when a later entry's scan fails.
|
// 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
|
// 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
|
// after the vault has already enumerated it. If that rescan fails the ID
|
||||||
@@ -28,7 +22,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -99,49 +92,6 @@ func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
|||||||
return f.Fs.Open(name)
|
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
|
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||||
// yields the real ID "pgp-<keyID>".
|
// yields the real ID "pgp-<keyID>".
|
||||||
func writePGPUnlocker(
|
func writePGPUnlocker(
|
||||||
@@ -277,102 +227,3 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
assert.True(t, unlockers[0].IsCurrent)
|
assert.True(t, unlockers[0].IsCurrent)
|
||||||
assert.False(t, unlockers[1].IsCurrent)
|
assert.False(t, unlockers[1].IsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
|
||||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
|
||||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
|
||||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
metadata string
|
|
||||||
wantIDs []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "not JSON",
|
|
||||||
metadata: "not json",
|
|
||||||
wantIDs: []string{healthyID},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GPG key ID of the wrong type",
|
|
||||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GPG key ID missing",
|
|
||||||
metadata: `{"type": "pgp"}`,
|
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newListTestVault(t, 2)
|
|
||||||
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
|
|
||||||
listTestVaultName, listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirTwo, listTestMetadataFileName)
|
|
||||||
require.NoError(t, afero.WriteFile(
|
|
||||||
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
|
|
||||||
))
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
require.Len(t, unlockers, len(tt.wantIDs))
|
|
||||||
|
|
||||||
for i, wantID := range tt.wantIDs {
|
|
||||||
assert.Equal(t, wantID, unlockers[i].ID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
|
|
||||||
// metadata file cannot be checked for or cannot be read is left out of the
|
|
||||||
// listing, and the other unlocker is still listed with its real ID. The
|
|
||||||
// failing one sorts first, so finding the other's ID has to step past it
|
|
||||||
// as well.
|
|
||||||
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
failingPath := filepath.Join(listTestStateDir, "vaults.d",
|
|
||||||
listTestVaultName, listTestUnlockersDirName,
|
|
||||||
listTestUnlockerDirOne, listTestMetadataFileName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wrap func(base afero.Fs) afero.Fs
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "checking for the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reading the file fails",
|
|
||||||
wrap: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := tt.wrap(newListTestVault(t, 2))
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
|
||||||
"only the unlocker with usable metadata may be listed")
|
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
|
||||||
"the listed row must carry the real unlocker ID")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
// Unreadable Directory Tests
|
|
||||||
//
|
|
||||||
// The checks that guard adding a PGP unlocker (is this key already an
|
|
||||||
// unlocker?), removing the last unlocker and removing a vault (does the
|
|
||||||
// vault hold secrets?), and importing a mnemonic (does the vault already
|
|
||||||
// have a long-term key?) each look at the vault on disk before acting.
|
|
||||||
// When that look fails they must refuse to act, not read the failure as
|
|
||||||
// "nothing there" and go ahead.
|
|
||||||
//
|
|
||||||
// The tests make the look fail with a wrapper around the in-memory
|
|
||||||
// filesystem, which the state directory lock refuses. So they call the
|
|
||||||
// function each command runs once it holds the lock, such as removeVault
|
|
||||||
// for RemoveVault.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
|
|
||||||
// PGP unlocker tests generate, and the --keyid they pass.
|
|
||||||
unreadableTestGPGUserID = "unlocker-test@example.com"
|
|
||||||
|
|
||||||
// unreadableTestSecretName is the secret stored in the vaults the
|
|
||||||
// removal tests remove from.
|
|
||||||
unreadableTestSecretName = "api-key"
|
|
||||||
|
|
||||||
// unreadableTestOtherVault is a second vault for the vault removal
|
|
||||||
// test, since the last vault can never be removed.
|
|
||||||
unreadableTestOtherVault = "work"
|
|
||||||
|
|
||||||
// unreadableTestSecretsDirName is the directory holding a vault's
|
|
||||||
// secrets, and unreadableTestCurrentFileName the per-secret file
|
|
||||||
// naming its current version.
|
|
||||||
unreadableTestSecretsDirName = "secrets.d"
|
|
||||||
unreadableTestCurrentFileName = "current"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errStatFailed is returned by statFailFs in place of a successful stat.
|
|
||||||
var errStatFailed = errors.New("input/output error")
|
|
||||||
|
|
||||||
// statFailFs fails every Stat of one path, as an I/O or permission error
|
|
||||||
// on that path would.
|
|
||||||
type statFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
|
|
||||||
if name == f.path {
|
|
||||||
return nil, errStatFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.Stat(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errOpenFailed is returned by openFailFs in place of a successful open.
|
|
||||||
var errOpenFailed = errors.New("permission denied")
|
|
||||||
|
|
||||||
// openFailFs fails every Open of one path, as a directory without read
|
|
||||||
// permission does: checking that it exists succeeds, listing it fails.
|
|
||||||
type openFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
||||||
func (f *openFailFs) Open(name string) (afero.File, error) {
|
|
||||||
if name == f.path {
|
|
||||||
return nil, errOpenFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.Open(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testVaultDir returns the directory of the named vault in the synthetic
|
|
||||||
// state directory built by newListTestVault.
|
|
||||||
func testVaultDir(vaultName string) string {
|
|
||||||
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestInstance returns a CLI instance on fs whose output is discarded.
|
|
||||||
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
|
|
||||||
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertDirEntries asserts that dir holds exactly the named entries.
|
|
||||||
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
names = append(names, entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, want, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
|
||||||
// without a passphrase there, with a subkey for encryption, and returns the
|
|
||||||
// key's fingerprint.
|
|
||||||
func newTestGPGKey(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
|
|
||||||
// socket, which is created inside GNUPGHOME there.
|
|
||||||
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
|
|
||||||
t.Setenv("GNUPGHOME", gnupgHome)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
// Stop the gpg-agent that key generation starts; cleanups run in
|
|
||||||
// reverse order, so this happens before its directory is removed.
|
|
||||||
// t.Context is already canceled when cleanup runs.
|
|
||||||
ctx := context.WithoutCancel(t.Context())
|
|
||||||
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
||||||
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
||||||
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
|
|
||||||
).CombinedOutput()
|
|
||||||
require.NoError(t, err, "generating the test GPG key: %s", output)
|
|
||||||
|
|
||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
|
|
||||||
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
||||||
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
||||||
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
|
|
||||||
).CombinedOutput()
|
|
||||||
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
|
|
||||||
output)
|
|
||||||
|
|
||||||
return fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
|
|
||||||
// against fs.
|
|
||||||
func addTestPGPUnlocker(fs afero.Fs) error {
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
||||||
|
|
||||||
return instance.addPGPUnlocker(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
|
|
||||||
// a key that already has one fails, and creates no unlocker directory,
|
|
||||||
// when unlockers.d or the existing unlocker's metadata file cannot be
|
|
||||||
// read; and, as the control case, that the existing unlocker is refused
|
|
||||||
// as a duplicate when everything can be read.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
||||||
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
|
|
||||||
fingerprint := newTestGPGKey(t)
|
|
||||||
unlockersDir := filepath.Join(
|
|
||||||
testVaultDir(listTestVaultName), listTestUnlockersDirName)
|
|
||||||
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
|
|
||||||
|
|
||||||
// newVaultWithDuplicate returns a vault holding an unlocker for the
|
|
||||||
// test key, beside the one newListTestVault writes.
|
|
||||||
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
|
|
||||||
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
|
|
||||||
fingerprint)
|
|
||||||
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
failFs func(base afero.Fs) afero.Fs
|
|
||||||
wantErr error
|
|
||||||
// wantPath is the path the error must name.
|
|
||||||
wantPath string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unlockers.d unreadable",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &unlockersDirFailFs{Fs: base}
|
|
||||||
},
|
|
||||||
wantErr: errUnlockersDirUnreadable,
|
|
||||||
wantPath: unlockersDir,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "existing unlocker's metadata unreadable",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &metadataReadFailFs{
|
|
||||||
Fs: base,
|
|
||||||
unreadablePath: filepath.Join(
|
|
||||||
duplicateDir, listTestMetadataFileName),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: errMetadataUnreadable,
|
|
||||||
wantPath: duplicateDir,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
base := newVaultWithDuplicate(t)
|
|
||||||
|
|
||||||
err := addTestPGPUnlocker(tt.failFs(base))
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
||||||
assert.Contains(t, err.Error(), tt.wantPath,
|
|
||||||
"the error must name what it could not read")
|
|
||||||
assertDirEntries(t, base, unlockersDir,
|
|
||||||
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("duplicate refused", func(t *testing.T) {
|
|
||||||
base := newVaultWithDuplicate(t)
|
|
||||||
|
|
||||||
err := addTestPGPUnlocker(base)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
||||||
assertDirEntries(t, base, unlockersDir,
|
|
||||||
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeTestSecret stores a secret with a current-version pointer, which is
|
|
||||||
// what makes it count as a secret, in the given vault directory.
|
|
||||||
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
secretDir := filepath.Join(
|
|
||||||
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
|
|
||||||
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(secretDir, unreadableTestCurrentFileName),
|
|
||||||
[]byte("20260809.001"), listTestFilePerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
|
|
||||||
// unlocker is kept when the secrets it protects cannot be counted.
|
|
||||||
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
||||||
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
secretsDir,
|
|
||||||
filepath.Join(secretsDir, unreadableTestSecretName,
|
|
||||||
unreadableTestCurrentFileName),
|
|
||||||
} {
|
|
||||||
t.Run(filepath.Base(path), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
instance, cmd := newTestInstance(&statFailFs{Fs: base, path: path})
|
|
||||||
|
|
||||||
err := instance.removeUnlocker(
|
|
||||||
"pgp-"+listTestGPGKeyID+"A", false, cmd)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
|
||||||
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
|
|
||||||
// kept when whether it holds secrets cannot be determined: when checking
|
|
||||||
// that secrets.d exists fails, and when it exists but cannot be listed.
|
|
||||||
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(unreadableTestOtherVault)
|
|
||||||
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
failFs func(base afero.Fs) afero.Fs
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "check fails",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &statFailFs{Fs: base, path: secretsDir}
|
|
||||||
},
|
|
||||||
wantErr: errStatFailed,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "listing fails",
|
|
||||||
failFs: func(base afero.Fs) afero.Fs {
|
|
||||||
return &openFailFs{Fs: base, path: secretsDir}
|
|
||||||
},
|
|
||||||
wantErr: errOpenFailed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
writeTestSecret(t, base, vaultDir)
|
|
||||||
instance, cmd := newTestInstance(tt.failFs(base))
|
|
||||||
|
|
||||||
err := instance.removeVault(cmd, unreadableTestOtherVault, false)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(base, vaultDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "the vault must not be removed")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
|
|
||||||
// stops when whether the vault already has a long-term key cannot be
|
|
||||||
// determined.
|
|
||||||
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
instance, cmd := newTestInstance(&statFailFs{
|
|
||||||
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
|
||||||
})
|
|
||||||
|
|
||||||
err := instance.importMnemonic(cmd, listTestVaultName)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// usageHeading starts the usage text cobra prints after an error.
|
|
||||||
const usageHeading = "Usage:"
|
|
||||||
|
|
||||||
// A command called wrongly gets usage after its error; a command that
|
|
||||||
// fails while running gets its error alone. Either way the command fails
|
|
||||||
// and its error is shown exactly once.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
|
||||||
func TestUsageOnlyForCallErrors(t *testing.T) {
|
|
||||||
// No vault in the state directory, so `get x` fails while running.
|
|
||||||
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
call string
|
|
||||||
wantUsage bool
|
|
||||||
}{
|
|
||||||
{call: "get", wantUsage: true},
|
|
||||||
{call: "get x y", wantUsage: true},
|
|
||||||
{call: "get --no-such-flag x", wantUsage: true},
|
|
||||||
{call: "generate secret x --length abc", wantUsage: true},
|
|
||||||
{call: "import x", wantUsage: true},
|
|
||||||
{call: "get x", wantUsage: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
|
|
||||||
require.Error(t, err, "%q should fail", tt.call)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(output, err.Error()),
|
|
||||||
"%q should show its error once:\n%s", tt.call, output)
|
|
||||||
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
|
|
||||||
"usage shown for %q:\n%s", tt.call, output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+75
-106
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -84,9 +85,6 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.CreateVault(cmd, args[0])
|
return cli.CreateVault(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -138,9 +136,6 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.VaultImport(cmd, vaultName)
|
return cli.VaultImport(cmd, vaultName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -233,14 +228,28 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePassphrase returns the unlock passphrase from the environment,
|
// setMnemonicEnv sets the mnemonic environment variable and returns a
|
||||||
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
|
// function that restores the previous value
|
||||||
// returned cleanup function must be deferred by the caller.
|
func setMnemonicEnv(mnemonicStr string) func() {
|
||||||
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
if cli.UnlockPassphrase != nil {
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if originalMnemonic != "" {
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePassphrase returns the unlock passphrase from the environment or
|
||||||
|
// prompts the user for it with confirmation
|
||||||
|
func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
||||||
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
return cli.UnlockPassphrase, func() {}, nil
|
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Prompting user for unlock passphrase")
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
@@ -248,29 +257,40 @@ func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error)
|
|||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
return passphraseBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault
|
||||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
// Get or prompt for mnemonic
|
||||||
if err != nil {
|
var mnemonicStr string
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
if err != nil {
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
return err
|
|
||||||
|
mnemonicStr = envMnemonic
|
||||||
|
} else {
|
||||||
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
// Read mnemonic securely without echo
|
||||||
|
mnemonicBuffer, err := secret.ReadPassphrase(
|
||||||
|
"Enter your BIP39 mnemonic phrase: ")
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
}
|
}
|
||||||
defer cleanupMnemonic()
|
|
||||||
|
|
||||||
mnemonicStr := mnemonic.String()
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
return errMnemonicEmpty
|
return errMnemonicEmpty
|
||||||
}
|
}
|
||||||
@@ -283,16 +303,12 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
return errInvalidMnemonicPhrase
|
return errInvalidMnemonicPhrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for the unlocker passphrase before creating the vault, so that
|
// Set mnemonic in environment for CreateVault to use
|
||||||
// stopping at the prompt leaves no vault without an unlocker behind
|
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
defer restoreMnemonicEnv()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanupPassphrase()
|
|
||||||
|
|
||||||
// Create the vault - it will handle key derivation internally
|
// Create the vault - it will handle key derivation internally
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -314,6 +330,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Get or prompt for passphrase
|
||||||
|
passphraseBuffer, err := resolvePassphrase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
@@ -331,13 +354,7 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
|
|
||||||
// SelectVault selects a vault as the current one
|
// SelectVault selects a vault as the current one
|
||||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
err := vault.SelectVault(cli.fs, cli.stateDir, name)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
err = vault.SelectVault(cli.fs, cli.stateDir, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -371,23 +388,18 @@ func (cli *Instance) vaultImportPreflight(
|
|||||||
// Check if vault already has a public key
|
// Check if vault already has a public key
|
||||||
pubKeyPath := vaultDir + "/pub.age"
|
pubKeyPath := vaultDir + "/pub.age"
|
||||||
|
|
||||||
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
_, err = cli.fs.Stat(pubKeyPath)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
vaultName, errVaultHasLongTermKey)
|
vaultName, errVaultHasLongTermKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get mnemonic from environment
|
// Get mnemonic from environment
|
||||||
if cli.Mnemonic == nil {
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
if mnemonic == "" {
|
||||||
return "", "", "", errMnemonicEnvNotSet
|
return "", "", "", errMnemonicEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := cli.Mnemonic.String()
|
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonic)
|
mnemonicWords := strings.Fields(mnemonic)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
@@ -430,26 +442,8 @@ func updateVaultImportMetadata(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
// VaultImport imports a mnemonic into a specific vault
|
||||||
// directory lock while importMnemonic runs
|
|
||||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||||
err := vault.ValidateVaultName(vaultName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.importMnemonic(cmd, vaultName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// importMnemonic gives the vault a long-term key derived from the mnemonic
|
|
||||||
// and a passphrase unlocker
|
|
||||||
func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error {
|
|
||||||
secret.Debug("Importing mnemonic into vault",
|
secret.Debug("Importing mnemonic into vault",
|
||||||
"vault_name", vaultName, "state_dir", cli.stateDir)
|
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
@@ -484,7 +478,7 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
secret.Debug("Storing long-term public key",
|
secret.Debug("Storing long-term public key",
|
||||||
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
|
err = afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -510,13 +504,17 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get passphrase from environment variable
|
// Get passphrase from environment variable
|
||||||
passphraseBuffer := cli.UnlockPassphrase
|
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||||
if passphraseBuffer == nil {
|
if passphraseStr == "" {
|
||||||
return errPassphraseEnvNotSet
|
return errPassphraseEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
|
// Create secure buffer for passphrase
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
@@ -538,26 +536,17 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vaultHasSecrets reports whether the vault directory contains any secrets
|
// vaultHasSecrets reports whether the vault directory contains any secrets
|
||||||
func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, error) {
|
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, secretsDir)
|
exists, _ := afero.DirExists(cli.fs, secretsDir)
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to check secrets directory %s: %w",
|
|
||||||
secretsDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return false, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to read secrets directory %s: %w",
|
|
||||||
secretsDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(entries) > 0, nil
|
return err == nil && len(entries) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchAwayFromVault selects another vault as current before removal
|
// switchAwayFromVault selects another vault as current before removal
|
||||||
@@ -586,25 +575,8 @@ func (cli *Instance) switchAwayFromVault(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveVault removes a vault, holding the state directory lock while
|
// RemoveVault removes a vault with safety checks
|
||||||
// removeVault runs
|
|
||||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
err := vault.ValidateVaultName(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
return cli.removeVault(cmd, name, force)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeVault removes a vault with safety checks
|
|
||||||
func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) error {
|
|
||||||
// Get list of all vaults
|
// Get list of all vaults
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -638,10 +610,7 @@ func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault has secrets
|
// Check if vault has secrets
|
||||||
hasSecrets, err := cli.vaultHasSecrets(vaultDir)
|
hasSecrets := cli.vaultHasSecrets(vaultDir)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require --force if vault has secrets
|
// Require --force if vault has secrets
|
||||||
if hasSecrets && !force {
|
if hasSecrets && !force {
|
||||||
@@ -657,7 +626,7 @@ func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the vault directory
|
// Remove the vault directory
|
||||||
err = secret.RemoveDirAtomic(cli.fs, vaultDir)
|
err = cli.fs.RemoveAll(vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-22
@@ -54,9 +54,6 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
destroySecrets := cli.readSecretEnv()
|
|
||||||
defer destroySecrets()
|
|
||||||
|
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -175,8 +172,6 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
currentVersion = ""
|
currentVersion = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
||||||
|
|
||||||
// Get long-term key for decrypting metadata
|
// Get long-term key for decrypting metadata
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,12 +249,6 @@ func (cli *Instance) PromoteVersion(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -276,7 +265,9 @@ func (cli *Instance) PromoteVersion(
|
|||||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
exists, err := secret.VersionExists(cli.fs, secretDir, version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
@@ -306,12 +297,6 @@ func (cli *Instance) RemoveVersion(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,7 +323,9 @@ func (cli *Instance) RemoveVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
exists, err = secret.VersionExists(cli.fs, secretDir, version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
@@ -361,9 +348,7 @@ func (cli *Instance) RemoveVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the version directory
|
// Remove the version directory
|
||||||
versionDir := filepath.Join(secretDir, "versions", version)
|
err = cli.fs.RemoveAll(versionDir)
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(cli.fs, versionDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove version: %w", err)
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||||
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
|
|
||||||
// - TestVersionCommandStructure: Tests command structure and help text
|
// - TestVersionCommandStructure: Tests command structure and help text
|
||||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||||
//
|
//
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
@@ -45,17 +43,6 @@ const (
|
|||||||
testStateDir = "/test/state"
|
testStateDir = "/test/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to add a version of the "test/secret" secret to the
|
// Helper function to add a version of the "test/secret" secret to the
|
||||||
// vault with proper buffer protection
|
// vault with proper buffer protection
|
||||||
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||||
@@ -72,8 +59,11 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
|||||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Set mnemonic for testing
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -91,13 +81,11 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsCommand(t *testing.T) {
|
func TestListVersionsCommand(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs)
|
||||||
@@ -106,8 +94,6 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = cli.Mnemonic
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -151,9 +137,8 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
assert.Equal(t, 2, versionLines)
|
assert.Equal(t, 2, versionLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -174,9 +159,8 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestPromoteVersionCommand(t *testing.T) {
|
func TestPromoteVersionCommand(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -188,8 +172,6 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -206,10 +188,7 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
// Current should be version-2
|
// Current should be version-2
|
||||||
value, err := vlt.GetSecret("test/secret")
|
value, err := vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
||||||
|
|
||||||
// Promote first version
|
// Promote first version
|
||||||
firstVersion := versions[1] // Older version
|
firstVersion := versions[1] // Older version
|
||||||
@@ -232,17 +211,13 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
assert.Contains(t, outputStr, firstVersion)
|
assert.Contains(t, outputStr, firstVersion)
|
||||||
|
|
||||||
// Verify current is now version-1
|
// Verify current is now version-1
|
||||||
promoted, err := vlt.GetSecret("test/secret")
|
value, err = vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
defer promoted.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -269,13 +244,11 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestGetSecretWithVersion(t *testing.T) {
|
func TestGetSecretWithVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs)
|
||||||
@@ -284,8 +257,6 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt.Mnemonic = cli.Mnemonic
|
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -305,8 +276,8 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
|
|
||||||
// Test getting the current version
|
// Test getting current version (empty version string)
|
||||||
err = cli.GetSecret(cmd, "test/secret")
|
err = cli.GetSecretWithVersion(cmd, "test/secret", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "version-2", buf.String())
|
assert.Equal(t, "version-2", buf.String())
|
||||||
|
|
||||||
@@ -319,48 +290,6 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
assert.Equal(t, "version-1", buf.String())
|
assert.Equal(t, "version-1", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSecretWritesBinaryValue(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
setupTestVault(t, fs)
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
|
|
||||||
require.False(t, utf8.Valid(value))
|
|
||||||
// A copy, since storing a value wipes the slice it came from
|
|
||||||
addTestSecret(t, vlt, bytes.Clone(value), false)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 1)
|
|
||||||
|
|
||||||
cmd := newRootCmd()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
cmd.SetOut(&buf)
|
|
||||||
|
|
||||||
// Each writes exactly the stored bytes, with no trailing newline
|
|
||||||
err = cli.GetSecret(cmd, "test/secret")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, value, buf.Bytes())
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
|
|
||||||
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, value, buf.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // reads process environment to determine the state dir
|
//nolint:paralleltest // reads process environment to determine the state dir
|
||||||
func TestVersionCommandStructure(t *testing.T) {
|
func TestVersionCommandStructure(t *testing.T) {
|
||||||
// Test that version commands are properly structured
|
// Test that version commands are properly structured
|
||||||
@@ -384,9 +313,8 @@ func TestVersionCommandStructure(t *testing.T) {
|
|||||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
|
||||||
// a crash at any moment, finds either the old content or the new, never a
|
|
||||||
// partial file. The data goes into a temporary file that afero.TempFile
|
|
||||||
// creates with mode 0600 in the same directory (a rename is only atomic
|
|
||||||
// within one filesystem), is synced to disk, and is renamed over path. The
|
|
||||||
// temporary file is removed if any step fails.
|
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
|
||||||
"."+filepath.Base(path)+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tmp.Write(data)
|
|
||||||
if err == nil {
|
|
||||||
err = tmp.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
closeErr := tmp.Close()
|
|
||||||
if err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
err = fs.Rename(tmp.Name(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
_ = fs.Remove(tmp.Name())
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TempDirFor creates an empty temporary directory in which to build the
|
|
||||||
// directory target before renaming it into place, or into which to move
|
|
||||||
// target before deleting it. It is made in target's grandparent: on the
|
|
||||||
// same filesystem, so the rename is atomic, and outside target's parent,
|
|
||||||
// the directory that is listed to find vaults, secrets, versions and
|
|
||||||
// unlockers, so one left behind by a crash is never taken for one of them.
|
|
||||||
// Its name leaves out target's, which may already be as long as a file name
|
|
||||||
// can be.
|
|
||||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
|
||||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to create temporary directory for %s: %w", target, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteDir calls write to write the files of the new directory dir into a
|
|
||||||
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
|
||||||
// neither a failure nor a crash leaves dir half-written; on a failure the
|
|
||||||
// temporary directory is removed, and a failure to remove it is returned
|
|
||||||
// along with the first. A directory cannot be replaced in one rename, so if
|
|
||||||
// dir already exists, WriteDir fails without calling write.
|
|
||||||
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
|
||||||
exists, err := afero.Exists(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the directory the finished one is renamed into
|
|
||||||
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := TempDirFor(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = write(tmp)
|
|
||||||
if err == nil {
|
|
||||||
err = fs.Rename(tmp, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
removeErr := fs.RemoveAll(tmp)
|
|
||||||
if removeErr != nil {
|
|
||||||
err = errors.Join(err,
|
|
||||||
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
|
||||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
|
||||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
|
||||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
|
||||||
tmp, err := TempDirFor(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
|
|
||||||
if err != nil {
|
|
||||||
_ = fs.Remove(tmp)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fs.RemoveAll(tmp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,901 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errInjected = errors.New("injected failure")
|
|
||||||
|
|
||||||
// The kinds of change hookFs passes to before.
|
|
||||||
const (
|
|
||||||
opCreate = "create"
|
|
||||||
opOpen = "open"
|
|
||||||
opSync = "sync"
|
|
||||||
opMkdir = "mkdir"
|
|
||||||
opRemove = "remove"
|
|
||||||
opRename = "rename"
|
|
||||||
)
|
|
||||||
|
|
||||||
// currentFile is the file in a secret's directory that names its current
|
|
||||||
// version.
|
|
||||||
const currentFile = "current"
|
|
||||||
|
|
||||||
// unlockerMetadataFile is the file a new unlocker writes last.
|
|
||||||
const unlockerMetadataFile = "unlocker-metadata.json"
|
|
||||||
|
|
||||||
// privKeyFile is the file that holds the encrypted private key of a version
|
|
||||||
// or of a passphrase unlocker.
|
|
||||||
const privKeyFile = "priv.age"
|
|
||||||
|
|
||||||
// unlockerPassphrase protects the passphrase unlockers the tests create.
|
|
||||||
//
|
|
||||||
//nolint:gosec // G101: test data, not a real credential
|
|
||||||
const unlockerPassphrase = "unlocker passphrase"
|
|
||||||
|
|
||||||
// hookFs passes every call through to Fs, but first calls before for each
|
|
||||||
// call that changes the filesystem, and for each Sync of a file opened
|
|
||||||
// through it, with the path it changes (the new path, for Rename). A test
|
|
||||||
// uses before to inspect the tree at every point where a crash could stop
|
|
||||||
// the code under test, or returns an error from it to make that call fail.
|
|
||||||
// If opened is set, OpenFile also tells it the mode it opens each file with.
|
|
||||||
type hookFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
before func(op, path string) error
|
|
||||||
opened func(path string, perm os.FileMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hookFile is a file opened through hookFs.
|
|
||||||
type hookFile struct {
|
|
||||||
afero.File
|
|
||||||
|
|
||||||
before func(op, path string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f hookFile) Sync() error {
|
|
||||||
err := f.before(opSync, f.Name())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.File.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) Create(name string) (afero.File, error) {
|
|
||||||
err := h.before(opCreate, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := h.Fs.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hookFile{File: file, before: h.before}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) OpenFile(
|
|
||||||
name string, flag int, perm os.FileMode,
|
|
||||||
) (afero.File, error) {
|
|
||||||
err := h.before(opOpen, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.opened != nil {
|
|
||||||
h.opened(name, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := h.Fs.OpenFile(name, flag, perm)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hookFile{File: file, before: h.before}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
|
||||||
err := h.before(opMkdir, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Mkdir(name, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
|
||||||
err := h.before(opMkdir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.MkdirAll(path, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Remove(name string) error {
|
|
||||||
err := h.before(opRemove, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Remove(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) RemoveAll(path string) error {
|
|
||||||
err := h.before(opRemove, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.RemoveAll(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Rename(oldname, newname string) error {
|
|
||||||
err := h.before(opRename, newname)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Fs.Rename(oldname, newname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testFilesystem is a filesystem to run a test on, with a directory in it
|
|
||||||
// to work in.
|
|
||||||
type testFilesystem struct {
|
|
||||||
name string
|
|
||||||
open func(t *testing.T) (afero.Fs, string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testFilesystems are the in-memory filesystem that most tests use and the
|
|
||||||
// real one: every rename-based guarantee is checked on both.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // read-only table shared by the tests below
|
|
||||||
var testFilesystems = []testFilesystem{
|
|
||||||
{"memory", func(*testing.T) (afero.Fs, string) {
|
|
||||||
return afero.NewMemMapFs(), "/test"
|
|
||||||
}},
|
|
||||||
{"real", func(t *testing.T) (afero.Fs, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return afero.NewOsFs(), t.TempDir()
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirNames lists the names in dir.
|
|
||||||
func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
names = append(names, entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirFiles returns the contents of the files in dir, by name.
|
|
||||||
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
files := map[string]string{}
|
|
||||||
|
|
||||||
for _, name := range dirNames(t, fs, dir) {
|
|
||||||
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files[name] = string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
|
||||||
// and returns it.
|
|
||||||
func writeLongTermKey(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string,
|
|
||||||
) *age.X25519Identity {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
|
|
||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
|
||||||
[]byte(ltIdentity.Recipient().String()), 0o600))
|
|
||||||
|
|
||||||
return ltIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// newVaultWithSecret creates the vault name under stateDir from the test
|
|
||||||
// mnemonic, with a secret "shared" in it that holds value.
|
|
||||||
func newVaultWithSecret(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir, name, value string,
|
|
||||||
) *vault.Vault {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
|
||||||
defer buffer.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("shared", buffer, false))
|
|
||||||
|
|
||||||
return vlt
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteFileAtomicReplacesFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs, dir := tfs.open(t)
|
|
||||||
path := filepath.Join(dir, currentFile)
|
|
||||||
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
|
|
||||||
|
|
||||||
data, err := afero.ReadFile(fs, path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "new", string(data))
|
|
||||||
|
|
||||||
info, err := fs.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
|
|
||||||
|
|
||||||
// No temporary file is left next to it
|
|
||||||
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
path := filepath.Join(dir, currentFile)
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
|
||||||
if op == opRename {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(fs, path, []byte("new"))
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
data, err := afero.ReadFile(base, path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "old", string(data))
|
|
||||||
|
|
||||||
// The temporary file is removed again
|
|
||||||
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
|
|
||||||
// directory stands, which a crash could stop half-way, and that it leaves
|
|
||||||
// nothing behind.
|
|
||||||
func TestRemoveDirAtomic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
listed := filepath.Join(dir, "secrets.d")
|
|
||||||
target := filepath.Join(listed, "doomed")
|
|
||||||
|
|
||||||
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base,
|
|
||||||
filepath.Join(target, currentFile), []byte("20231216.001")))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRemove && strings.HasPrefix(path, target) {
|
|
||||||
t.Errorf("deleted %s where it stands", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs, target))
|
|
||||||
|
|
||||||
// Gone, and no temporary directory is left in the directory
|
|
||||||
// that is listed or in the one above it
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLongestNames adds a secret to a vault and removes the vault, both
|
|
||||||
// named with 255 bytes, the most a file name may have, on the real
|
|
||||||
// filesystem: the temporary directories they use must fit that limit too.
|
|
||||||
func TestLongestNames(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const longestName = 255
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
name := strings.Repeat("a", longestName)
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret(name, value, false))
|
|
||||||
|
|
||||||
got, err := vlt.GetSecret(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer got.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("long"), got.Bytes())
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
|
|
||||||
assert.NoDirExists(t, vaultDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
|
|
||||||
// another vault, as a forced move between vaults does, and makes the last
|
|
||||||
// step that completes the copy fail. The secret it was to replace must
|
|
||||||
// still be there unchanged: it may go only once its replacement is whole.
|
|
||||||
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
|
||||||
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
|
||||||
|
|
||||||
// The copy is complete once its current file is written
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(path) == currentFile {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err := vault.NewVault(fs, stateDir, "dest").
|
|
||||||
CopySecretAllVersions(src, "shared", "shared", true)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
value, err := dest.GetSecret("shared")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("old"), value.Bytes())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
|
|
||||||
// secret over another and removes one, and checks that none of them makes a
|
|
||||||
// directory directly in secrets.d or in a versions directory. Those are
|
|
||||||
// listed to find secrets and versions, so a temporary directory made there
|
|
||||||
// would be listed while half-built, and one left by a crash would stay.
|
|
||||||
func TestTempDirsStayOutOfListings(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
newVaultWithSecret(t, base, stateDir, "default", "first")
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
parent := filepath.Base(filepath.Dir(path))
|
|
||||||
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
|
|
||||||
t.Errorf("made %s where it is listed", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
vlt := vault.NewVault(fs, stateDir, "default")
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("second"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("shared", value, true))
|
|
||||||
require.NoError(t, vlt.AddSecret("other", value, false))
|
|
||||||
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, secret.RemoveDirAtomic(fs,
|
|
||||||
filepath.Join(vaultDir, "secrets.d", "shared")))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
|
|
||||||
// once after it returns, that the version directory either does not exist
|
|
||||||
// or holds all of its files: a crash at any point leaves no version that
|
|
||||||
// cannot be decrypted.
|
|
||||||
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
ltIdentity := writeLongTermKey(t, base, stateDir)
|
|
||||||
|
|
||||||
var versionDir string
|
|
||||||
|
|
||||||
checkVersionDir := func(string, string) error {
|
|
||||||
exists, err := afero.DirExists(base, versionDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
assert.ElementsMatch(t,
|
|
||||||
[]string{"pub.age", "value.age", privKeyFile, "metadata.age"},
|
|
||||||
dirNames(t, base, versionDir),
|
|
||||||
"version directory visible before it was complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: checkVersionDir}
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
|
||||||
versionDir = sv.Directory
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, sv.Save(value))
|
|
||||||
require.NoError(t, checkVersionDir("", ""))
|
|
||||||
|
|
||||||
got, err := sv.GetValue(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer got.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, "whole or nothing", got.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
|
|
||||||
// private key fail, after the value has been written, and checks that
|
|
||||||
// neither the version nor its temporary directory is left behind.
|
|
||||||
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
writeLongTermKey(t, base, stateDir)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(path) == privKeyFile {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
|
||||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("never stored"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.ErrorIs(t, sv.Save(value), errInjected)
|
|
||||||
|
|
||||||
// The secret directory holds only the empty versions directory
|
|
||||||
versionsDir := filepath.Dir(sv.Directory)
|
|
||||||
assert.Equal(t, []string{"versions"},
|
|
||||||
dirNames(t, base, filepath.Dir(versionsDir)))
|
|
||||||
assert.Empty(t, dirNames(t, base, versionsDir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCurrentFilesNeverMissing selects the current version, vault and
|
|
||||||
// unlocker again and checks, before each change this makes, that the file
|
|
||||||
// naming the current one exists: a reader or a crash never finds it
|
|
||||||
// missing.
|
|
||||||
func TestCurrentFilesNeverMissing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
// Created as the current unlocker
|
|
||||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
|
|
||||||
version, err := secret.GetCurrentVersion(base, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
path string
|
|
||||||
reselect func(fs afero.Fs) error
|
|
||||||
}{
|
|
||||||
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
|
|
||||||
return secret.SetCurrentVersion(fs, secretDir, version)
|
|
||||||
}},
|
|
||||||
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
|
|
||||||
return vault.SelectVault(fs, stateDir, testVaultName)
|
|
||||||
}},
|
|
||||||
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
|
|
||||||
return vault.NewVault(fs, stateDir, testVaultName).
|
|
||||||
SelectUnlocker(unlocker.GetID())
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
exists, err := afero.Exists(base, tc.path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
require.NoError(t, tc.reselect(fs))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
|
|
||||||
// temporary file with mode 0600, rather than wider and narrowed later, so
|
|
||||||
// that no other user can ever read it, and syncs it before renaming it into
|
|
||||||
// place, so that a crash cannot leave the file named but its data lost.
|
|
||||||
func TestWriteFileAtomicTempFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
|
|
||||||
var modes []os.FileMode
|
|
||||||
|
|
||||||
synced := false
|
|
||||||
fs := hookFs{
|
|
||||||
Fs: base,
|
|
||||||
before: func(op, _ string) error {
|
|
||||||
switch op {
|
|
||||||
case opSync:
|
|
||||||
synced = true
|
|
||||||
case opRename:
|
|
||||||
assert.True(t, synced, "renamed before syncing")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
opened: func(_ string, perm os.FileMode) {
|
|
||||||
modes = append(modes, perm)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
|
||||||
filepath.Join(dir, currentFile), []byte("new")))
|
|
||||||
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
|
|
||||||
// vault whose long-term key cannot be had: it must fail without writing
|
|
||||||
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
|
||||||
// it would replace.
|
|
||||||
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// No mnemonic, and no current unlocker to get the key from
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
|
||||||
t.Errorf("changed %s before getting the long-term key", path)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
|
||||||
CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
|
|
||||||
// creating a passphrase unlocker makes, that the unlocker's directory either
|
|
||||||
// does not exist or holds all of its files: a crash or a failure at any point
|
|
||||||
// leaves no partial unlocker.
|
|
||||||
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// The vault has no unlocker yet, so any directory in here is
|
|
||||||
// the new one
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
for _, name := range dirNames(t, base, unlockersDir) {
|
|
||||||
assert.ElementsMatch(t, files,
|
|
||||||
dirNames(t, base, filepath.Join(unlockersDir, name)),
|
|
||||||
"unlocker directory visible before it was complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
hooked := vault.NewVault(fs, stateDir, testVaultName)
|
|
||||||
hooked.Mnemonic = vlt.Mnemonic
|
|
||||||
|
|
||||||
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
|
|
||||||
// passphrase unlocker twice, each time with only the current unlocker to open
|
|
||||||
// the vault. The first replacement fails right after making the new unlocker
|
|
||||||
// current, so the old one is not removed. The second checks, before every
|
|
||||||
// change it makes, that the vault opens with the passphrase through its
|
|
||||||
// current unlocker, which is what a crash at that change would leave; once it
|
|
||||||
// returns, the vault must have one passphrase unlocker left.
|
|
||||||
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
||||||
|
|
||||||
// Every change after the switch to the new unlocker fails
|
|
||||||
switched := false
|
|
||||||
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if switched {
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
switched = op == opRename && path == currentUnlockerPath
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
|
|
||||||
replacing.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
unlockers, err := vlt.ListUnlockers()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
|
|
||||||
|
|
||||||
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
|
|
||||||
checked := hookFs{Fs: base, before: func(string, string) error {
|
|
||||||
assertOpens()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
replacing = vault.NewVault(checked, stateDir, testVaultName)
|
|
||||||
replacing.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assertOpens()
|
|
||||||
|
|
||||||
unlockers, err = vlt.ListUnlockers()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, unlockers, 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultOpensCheck returns a function that checks that the test vault under
|
|
||||||
// stateDir opens through its current unlocker, with passphrase, to the
|
|
||||||
// long-term key ltIdentity. Opening it takes a second, so an unlocker
|
|
||||||
// directory it has opened through before is not opened again: it must hold
|
|
||||||
// the same files as then.
|
|
||||||
func vaultOpensCheck(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
|
|
||||||
passphrase *memguard.LockedBuffer,
|
|
||||||
) func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
|
|
||||||
|
|
||||||
// The files of each unlocker directory the vault has opened through
|
|
||||||
opened := map[string]map[string]string{}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
|
|
||||||
|
|
||||||
if before, ok := opened[string(current)]; ok {
|
|
||||||
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opener := vault.NewVault(fs, stateDir, testVaultName)
|
|
||||||
opener.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
key, err := opener.UnlockVault()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
|
|
||||||
|
|
||||||
opened[string(current)] = files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
|
|
||||||
// a file has been written in it, and checks that neither the directory nor
|
|
||||||
// its temporary directory is left behind; and, when the temporary directory
|
|
||||||
// cannot be removed either, that both failures are reported.
|
|
||||||
func TestWriteDirFailureLeavesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base, dir := tfs.open(t)
|
|
||||||
listed := filepath.Join(dir, "unlockers.d")
|
|
||||||
target := filepath.Join(listed, "new")
|
|
||||||
|
|
||||||
writeThenFail := func(tmp string) error {
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(base,
|
|
||||||
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
|
|
||||||
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
err := secret.WriteDir(base, target, writeThenFail)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
// Nothing in the directory that is listed, nor beside it
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
|
||||||
if op == opRemove {
|
|
||||||
return os.ErrPermission
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
err = secret.WriteDir(fs, target, writeThenFail)
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
require.ErrorIs(t, err, os.ErrPermission)
|
|
||||||
assert.Empty(t, dirNames(t, base, listed))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
|
|
||||||
// write, when the directory already exists, and leaves the directory as it
|
|
||||||
// was: it never writes into a directory in place.
|
|
||||||
func TestWriteDirRefusesExistingDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs, dir := tfs.open(t)
|
|
||||||
target := filepath.Join(dir, "unlockers.d", "existing")
|
|
||||||
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
|
|
||||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
|
||||||
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
|
|
||||||
|
|
||||||
err := secret.WriteDir(fs, target, func(string) error {
|
|
||||||
t.Error("write called for a directory that exists")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, os.ErrExist)
|
|
||||||
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,12 +16,6 @@ const (
|
|||||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
|
|
||||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
|
||||||
// new unlocker's directory, keychain item and Secure Enclave key. It runs
|
|
||||||
// to the nanosecond, so that every new unlocker, even one added right
|
|
||||||
// after another, gets a directory of its own.
|
|
||||||
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// File system permission constants
|
// File system permission constants
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -33,9 +32,6 @@ func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
|||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
||||||
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { panic("not used") }
|
|
||||||
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) { panic("not used") }
|
|
||||||
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) { panic("not used") }
|
|
||||||
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
panic("not used")
|
panic("not used")
|
||||||
}
|
}
|
||||||
@@ -61,8 +57,6 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// Derive expected keys at two different indices to prove they differ.
|
// Derive expected keys at two different indices to prove they differ.
|
||||||
@@ -77,10 +71,9 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
t.Setenv(EnvMnemonic, testMnemonic)
|
||||||
defer mnemonic.Destroy()
|
|
||||||
|
|
||||||
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
result, err := getLongTermPrivateKey(fs, vault)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer result.Destroy()
|
defer result.Destroy()
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
|
|
||||||
// Step 3: Remove directory
|
// Step 3: Remove directory
|
||||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||||
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
|
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||||
@@ -233,20 +233,18 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: secret-<vault>-<hostname>-<time>
|
// Format: secret-<vault>-<hostname>-<date>
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
|
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey derives the long-term private key from mnemonic when
|
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
||||||
// it is not nil, else gets it through the current unlocker, which is given
|
|
||||||
// passphrase when it is a passphrase unlocker.
|
|
||||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||||
func getLongTermPrivateKey(
|
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
||||||
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
// Check if mnemonic is available in environment variable
|
||||||
) (*memguard.LockedBuffer, error) {
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
if mnemonic != nil {
|
if envMnemonic != "" {
|
||||||
// Read vault metadata to get the correct derivation index
|
// Read vault metadata to get the correct derivation index
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,7 +263,7 @@ func getLongTermPrivateKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
// Use mnemonic with the vault's actual derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
@@ -280,10 +278,6 @@ func getLongTermPrivateKey(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
|
||||||
passphraseUnlocker.Passphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current unlocker identity
|
// Get the current unlocker identity
|
||||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,12 +322,8 @@ func getLongTermPrivateKey(
|
|||||||
return ltPrivKeyBuffer, nil
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||||
// vault. The long-term key comes from mnemonic when it is not nil, else from
|
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||||
// the current unlocker, as getLongTermPrivateKey describes.
|
|
||||||
func CreateKeychainUnlocker(
|
|
||||||
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*KeychainUnlocker, error) {
|
|
||||||
// Check if we're on macOS
|
// Check if we're on macOS
|
||||||
if err := checkMacOSAvailable(); err != nil {
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -351,13 +341,16 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The unlocker directory is named after the keychain item
|
// Create unlocker directory using the keychain item name as the directory name
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
@@ -365,8 +358,6 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
|
||||||
|
|
||||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -374,7 +365,14 @@ func CreateKeychainUnlocker(
|
|||||||
}
|
}
|
||||||
defer agePrivKeyPassphrase.Destroy()
|
defer agePrivKeyPassphrase.Destroy()
|
||||||
|
|
||||||
// Step 3: Encrypt age private key with the generated passphrase
|
// Step 3: Store age recipient as plaintext
|
||||||
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
||||||
// Create a secure buffer for the private key
|
// Create a secure buffer for the private key
|
||||||
agePrivKeyStr := ageIdentity.String()
|
agePrivKeyStr := ageIdentity.String()
|
||||||
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
||||||
@@ -385,20 +383,31 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Get or derive the long-term private key
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
|
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Get or derive the long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 5: Encrypt long-term private key to the new age unlocker
|
// Step 6: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Prepare keychain data
|
// Write encrypted long-term private key
|
||||||
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Prepare keychain data
|
||||||
keychainData := KeychainData{
|
keychainData := KeychainData{
|
||||||
AgePublicKey: ageRecipient,
|
AgePublicKey: ageRecipient,
|
||||||
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
||||||
@@ -411,7 +420,12 @@ func CreateKeychainUnlocker(
|
|||||||
}
|
}
|
||||||
defer keychainDataBuffer.Destroy()
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 7: Prepare enhanced metadata
|
// Step 8: Store data in keychain
|
||||||
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Create and write enhanced metadata
|
||||||
keychainMetadata := KeychainUnlockerMetadata{
|
keychainMetadata := KeychainUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: "keychain",
|
Type: "keychain",
|
||||||
@@ -426,37 +440,10 @@ func CreateKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Write the unlocker's files and store the data in the keychain,
|
if err := afero.WriteFile(fs,
|
||||||
// the metadata last
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
metadataBytes, FilePerms); err != nil {
|
||||||
pubPath := filepath.Join(dir, "pub.txt")
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil {
|
|
||||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
privPath := filepath.Join(dir, "priv.age")
|
|
||||||
if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltKeyPath := filepath.Join(dir, "longterm.age")
|
|
||||||
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
|
||||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
|
|
||||||
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &KeychainUnlocker{
|
return &KeychainUnlocker{
|
||||||
|
|||||||
@@ -76,8 +76,13 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||||
func CreateKeychainUnlocker(
|
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||||
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
|
return nil, errKeychainNotSupported
|
||||||
) (*KeychainUnlocker, error) {
|
}
|
||||||
|
|
||||||
|
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
||||||
|
func getLongTermPrivateKey(
|
||||||
|
_ afero.Fs, _ VaultInterface,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,17 +19,6 @@ import (
|
|||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
"abandon abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||||
func writeTestPublicKey(
|
func writeTestPublicKey(
|
||||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||||
@@ -174,7 +163,7 @@ func newTestPassphraseUnlocker(
|
|||||||
return unlocker, ageIdentity, unlockerDir
|
return unlocker, ageIdentity, unlockerDir
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share real-FS state, order matters
|
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
|
||||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||||
// This test uses real filesystem
|
// This test uses real filesystem
|
||||||
if os.Getenv("CI") == "true" {
|
if os.Getenv("CI") == "true" {
|
||||||
@@ -206,42 +195,38 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
// Set test environment variable (cleaned up automatically)
|
||||||
defer passphrase.Destroy()
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
unlocker.Passphrase = passphrase
|
// Test getting identity from environment variable
|
||||||
|
t.Run("GetIdentityFromEnv", func(t *testing.T) {
|
||||||
|
identity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get identity from env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test getting identity with the passphrase the unlocker was given,
|
// Verify the identity matches what we expect
|
||||||
// twice: using it must leave it intact for the next use
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
|
|
||||||
for range 2 {
|
|
||||||
identity, err := unlocker.GetIdentity()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get identity with passphrase: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the identity matches what we expect
|
actualPubKey := identity.Recipient().String()
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
if actualPubKey != expectedPubKey {
|
||||||
|
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||||
actualPubKey := identity.Recipient().String()
|
expectedPubKey, actualPubKey)
|
||||||
if actualPubKey != expectedPubKey {
|
|
||||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
|
||||||
expectedPubKey, actualPubKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
unlocker.Passphrase = nil
|
// Unset the environment variable to test interactive prompt
|
||||||
|
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||||
|
|
||||||
// Test getting identity from prompt (this would require mocking the
|
// Test getting identity from prompt (this would require mocking the
|
||||||
// prompt). For real integration tests, we'd need a way to mock the
|
// prompt). For real integration tests, we'd need a way to mock the
|
||||||
// passphrase input. Here we just verify the error is what we expect
|
// passphrase input. Here we just verify the error is what we expect
|
||||||
// when no passphrase is available.
|
// when no passphrase is available.
|
||||||
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
|
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||||
// This should fail since we're not in an interactive terminal
|
// This should fail since we're not in an interactive terminal
|
||||||
_, err := unlocker.GetIdentity()
|
_, err := unlocker.GetIdentity()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Should have failed to get identity without a passphrase")
|
t.Errorf("Should have failed to get identity without passphrase env var")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package secret
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -126,7 +127,7 @@ func (p *PassphraseUnlocker) Remove() error {
|
|||||||
|
|
||||||
// For passphrase unlockers, we just need to remove the directory
|
// For passphrase unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// No external resources (like keychain items) to clean up
|
||||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
err := p.fs.RemoveAll(p.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -134,19 +135,28 @@ func (p *PassphraseUnlocker) Remove() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
|
// getPassphrase retrieves the passphrase from memory, environment, or
|
||||||
// the passphrase. The caller must destroy the returned buffer.
|
// user input. Returns a LockedBuffer for secure memory handling
|
||||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||||
|
// First check if we already have the passphrase
|
||||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||||
// Not NewBufferFromBytes, which would wipe p.Passphrase
|
// Return a copy of the passphrase buffer
|
||||||
passphrase := memguard.NewBuffer(p.Passphrase.Size())
|
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||||
passphrase.Copy(p.Passphrase.Bytes())
|
|
||||||
|
|
||||||
return passphrase, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("No passphrase in memory, prompting user")
|
Debug("No passphrase in memory, checking environment")
|
||||||
|
// Check environment variable for passphrase
|
||||||
|
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||||
|
if passphraseStr != "" {
|
||||||
|
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||||
|
// Convert to secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("No passphrase in environment, prompting user")
|
||||||
// Prompt for passphrase
|
// Prompt for passphrase
|
||||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -227,10 +227,8 @@ Passphrase: ` + testPassphrase + `
|
|||||||
// Test data
|
// Test data
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
defer mnemonic.Destroy()
|
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||||
|
|
||||||
// Set up vault structure for testing
|
// Set up vault structure for testing
|
||||||
@@ -246,7 +244,7 @@ Passphrase: ` + testPassphrase + `
|
|||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
// Create a test vault directory structure
|
// Create a test vault directory structure
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -292,7 +290,7 @@ Passphrase: ` + testPassphrase + `
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-122
@@ -155,18 +155,14 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||||
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
|
||||||
// directory and returns "pgp-unknown", so listing the other unlockers
|
|
||||||
// still works.
|
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: pgp-<keyid>
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
// The vault metadata is corrupt - this is a fatal error
|
||||||
"directory", p.Directory, "error", err)
|
// We cannot continue with a fallback ID as that would mask data corruption
|
||||||
|
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||||
return "pgp-unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "pgp-" + gpgKeyID
|
return "pgp-" + gpgKeyID
|
||||||
@@ -176,7 +172,7 @@ func (p *PGPUnlocker) GetID() string {
|
|||||||
func (p *PGPUnlocker) Remove() error {
|
func (p *PGPUnlocker) Remove() error {
|
||||||
// For PGP unlockers, we just need to remove the directory
|
// For PGP unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// No external resources (like keychain items) to clean up
|
||||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
err := p.fs.RemoveAll(p.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -201,92 +197,151 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
|||||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pgpMetadata.GPGKeyID == "" {
|
|
||||||
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
return pgpMetadata.GPGKeyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||||
// based on hostname and time
|
// based on hostname and date
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
// Format: hostname-pgp-YYYY-MM-DD
|
||||||
|
enrollmentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgpUnlockerDir returns the current vault and the directory in it for a
|
// preparePGPUnlockerDir checks GPG availability and creates the
|
||||||
// new PGP unlocker, named after the host and the time.
|
// unlocker directory in the current vault, returning the vault and the
|
||||||
|
// directory path.
|
||||||
//
|
//
|
||||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||||
func pgpUnlockerDir(
|
func preparePGPUnlockerDir(
|
||||||
fs afero.Fs, stateDir string,
|
fs afero.Fs, stateDir string,
|
||||||
) (VaultInterface, string, error) {
|
) (VaultInterface, string, error) {
|
||||||
|
// Check if GPG is available
|
||||||
|
err := checkGPGAvailable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the unlocker name based on hostname and time
|
// Generate the unlocker name based on hostname and date
|
||||||
unlockerName, err := generatePGPUnlockerName()
|
unlockerName, err := generatePGPUnlockerName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create unlocker directory using the generated name
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
||||||
|
|
||||||
|
err = fs.MkdirAll(unlockerDir, DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vault, unlockerDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||||
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
|
||||||
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
|
|
||||||
// Everything that can fail short of writing a file is done before anything
|
|
||||||
// is written, and the files are written through WriteDir, so a failure
|
|
||||||
// leaves no partial unlocker. The long-term key comes from mnemonic when it
|
|
||||||
// is not nil, else from the current unlocker, which is given passphrase when
|
|
||||||
// it is a passphrase unlocker.
|
|
||||||
func CreatePGPUnlocker(
|
func CreatePGPUnlocker(
|
||||||
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
fs afero.Fs, stateDir string, gpgKeyID string,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*PGPUnlocker, error) {
|
) (*PGPUnlocker, error) {
|
||||||
err := checkGPGAvailable()
|
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
|
||||||
vault.SetMnemonic(mnemonic)
|
|
||||||
vault.SetUnlockPassphrase(passphrase)
|
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
// Step 2: Store age recipient as plaintext
|
||||||
// keypair's private key to the GPG key
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
vault, ageIdentity, gpgKeyID)
|
|
||||||
|
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get or derive the long-term private key
|
||||||
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
|
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||||
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
||||||
|
ltPrivKeyData, ageIdentity.Recipient())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write encrypted long-term private key
|
||||||
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Encrypt age private key to the GPG key ID
|
||||||
|
// Use memguard to protect the private key in memory
|
||||||
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
|
||||||
|
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PGPUnlocker{
|
||||||
|
Directory: unlockerDir,
|
||||||
|
Metadata: pgpMetadata.UnlockerMetadata,
|
||||||
|
fs: fs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
|
||||||
|
// the unlocker metadata file, returning the metadata written.
|
||||||
|
func writePGPUnlockerMetadata(
|
||||||
|
fs afero.Fs, unlockerDir string, gpgKeyID string,
|
||||||
|
) (*PGPUnlockerMetadata, error) {
|
||||||
|
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
@@ -302,86 +357,14 @@ func CreatePGPUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Write the unlocker's files, the metadata last
|
err = afero.WriteFile(fs,
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||||
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
metadataBytes, FilePerms)
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PGPUnlocker{
|
return &pgpMetadata, nil
|
||||||
Directory: unlockerDir,
|
|
||||||
Metadata: pgpMetadata.UnlockerMetadata,
|
|
||||||
fs: fs,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
|
|
||||||
// to the new PGP unlocker's age keypair, and that keypair's private key
|
|
||||||
// encrypted to the GPG key gpgKeyID.
|
|
||||||
func encryptPGPUnlockerKeys(
|
|
||||||
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
|
||||||
) ([]byte, []byte, error) {
|
|
||||||
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
|
||||||
defer ltPrivKeyData.Destroy()
|
|
||||||
|
|
||||||
encryptedLtPrivKey, err := EncryptToRecipient(
|
|
||||||
ltPrivKeyData, ageIdentity.Recipient())
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use memguard to protect the private key in memory
|
|
||||||
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to encrypt age private key with GPG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return encryptedLtPrivKey, encryptedAgePrivKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
|
|
||||||
// metadata last.
|
|
||||||
func writePGPUnlockerFiles(
|
|
||||||
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
|
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
|
|
||||||
) error {
|
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
|
|
||||||
[]byte(ageRecipient.String()))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs,
|
|
||||||
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
|
|
||||||
const (
|
|
||||||
testGPGKeyID = "0123456789ABCDEF"
|
|
||||||
testGPGFingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeGPGScript is a gpg for which `gpg --version` succeeds and anything
|
|
||||||
// else fails.
|
|
||||||
const fakeGPGScript = `#!/bin/sh
|
|
||||||
[ "$*" = --version ]
|
|
||||||
`
|
|
||||||
|
|
||||||
// installFakeGPG makes fakeGPGScript the only gpg on PATH for the test.
|
|
||||||
func installFakeGPG(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
//nolint:gosec // G306: the script must be executable
|
|
||||||
err := os.WriteFile(filepath.Join(dir, "gpg"), []byte(fakeGPGScript), 0o700)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
|
|
||||||
// getting the vault's long-term key, which used to come after part of the
|
|
||||||
// unlocker was written, and asserts that nothing is written. Getting the key
|
|
||||||
// fails because there is no mnemonic and no current unlocker.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
|
||||||
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
|
||||||
installFakeGPG(t)
|
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
|
||||||
t.Errorf("changed %s", path)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err = secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
|
|
||||||
// the other, so on the same host and day, and checks that the second gets a
|
|
||||||
// directory of its own and leaves the first one's files as they were.
|
|
||||||
// CreatePGPUnlocker does not check whether the GPG key already has an
|
|
||||||
// unlocker, so the test key serves for both.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
|
||||||
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
|
||||||
installFakeGPG(t)
|
|
||||||
|
|
||||||
original := secret.GPGEncryptFunc
|
|
||||||
|
|
||||||
t.Cleanup(func() { secret.GPGEncryptFunc = original })
|
|
||||||
|
|
||||||
// Stands in for gpg, which the test does not have: "encrypts" by copying
|
|
||||||
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
|
|
||||||
return []byte(data.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
first, err := secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
firstFiles := dirFiles(t, fs, first.GetDirectory())
|
|
||||||
|
|
||||||
second, err := secret.CreatePGPUnlocker(
|
|
||||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
|
||||||
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
|
||||||
}
|
|
||||||
+11
-18
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -34,12 +35,6 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
|
||||||
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
|
||||||
// mnemonic to derive the long-term key from, and the passphrase for a
|
|
||||||
// current passphrase unlocker; nil for none.
|
|
||||||
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
|
||||||
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
|
||||||
CreatePassphraseUnlocker(
|
CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
@@ -81,12 +76,9 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the current version's value, with the
|
// GetValue retrieves and decrypts the current version's value using the
|
||||||
// vault's long-term key derived from mnemonic when it is not nil, else
|
// provided unlocker
|
||||||
// obtained through unlocker
|
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||||
func (s *Secret) GetValue(
|
|
||||||
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
DebugWith("Getting secret value",
|
DebugWith("Getting secret value",
|
||||||
slog.String("secret_name", s.Name),
|
slog.String("secret_name", s.Name),
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
slog.String("vault_name", s.vault.GetName()),
|
||||||
@@ -121,8 +113,9 @@ func (s *Secret) GetValue(
|
|||||||
// Create version object
|
// Create version object
|
||||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||||
|
|
||||||
if mnemonic != nil {
|
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||||
return s.getValueViaMnemonic(version, mnemonic.String())
|
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||||
|
return s.getValueViaMnemonic(version, envMnemonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||||
@@ -216,11 +209,11 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getValueViaMnemonic derives the vault's long-term key from the
|
// getValueViaMnemonic derives the vault's long-term key from the
|
||||||
// mnemonic and decrypts the version value with it.
|
// mnemonic in the environment and decrypts the version value with it.
|
||||||
func (s *Secret) getValueViaMnemonic(
|
func (s *Secret) getValueViaMnemonic(
|
||||||
version *Version, mnemonic string,
|
version *Version, envMnemonic string,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
Debug("Using mnemonic for direct long-term key derivation",
|
Debug("Using mnemonic from environment for direct long-term key derivation",
|
||||||
"secret_name", s.Name)
|
"secret_name", s.Name)
|
||||||
|
|
||||||
// Get vault directory to read metadata
|
// Get vault directory to read metadata
|
||||||
@@ -257,7 +250,7 @@ func (s *Secret) getValueViaMnemonic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Use mnemonic with the vault's derivation index from metadata
|
// Use mnemonic with the vault's derivation index from metadata
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to derive long-term key from mnemonic for secret",
|
Debug("Failed to derive long-term key from mnemonic for secret",
|
||||||
"error", err, "secret_name", s.Name)
|
"error", err, "secret_name", s.Name)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,7 +22,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
|||||||
"abandon abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
|
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
|
||||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
errNotImplementedInMock = errors.New("not implemented in mock")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ type MockVault struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
directory string
|
directory string
|
||||||
derivationIndex uint32
|
derivationIndex uint32
|
||||||
mnemonic *memguard.LockedBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) GetDirectory() (string, error) {
|
func (m *MockVault) GetDirectory() (string, error) {
|
||||||
@@ -63,11 +61,12 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
||||||
|
|
||||||
// Derive long-term key using the vault's derivation index
|
// Derive long-term key using the vault's derivation index
|
||||||
if m.mnemonic == nil {
|
mnemonic := os.Getenv(EnvMnemonic)
|
||||||
|
if mnemonic == "" {
|
||||||
return errMnemonicNotSet
|
return errMnemonicNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -108,16 +107,6 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
||||||
return nil, errNotImplementedInMock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
|
||||||
m.mnemonic = mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
func (m *MockVault) CreatePassphraseUnlocker(
|
func (m *MockVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*PassphraseUnlocker, error) {
|
) (*PassphraseUnlocker, error) {
|
||||||
@@ -245,13 +234,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share one vault, order matters
|
//nolint:paralleltest // uses t.Setenv (process-global environment)
|
||||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||||
// Create an in-memory filesystem for testing
|
// Create an in-memory filesystem for testing
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
// Set test mnemonic for direct encryption/decryption
|
||||||
defer mnemonic.Destroy()
|
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||||
|
|
||||||
// Set up a test vault structure
|
// Set up a test vault structure
|
||||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||||
@@ -265,7 +254,6 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
fs: fs,
|
fs: fs,
|
||||||
directory: vaultDir,
|
directory: vaultDir,
|
||||||
derivationIndex: 0,
|
derivationIndex: 0,
|
||||||
mnemonic: mnemonic,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
@@ -322,45 +310,26 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
|
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||||
// GetValue, given the mnemonic, derives the long-term key at the derivation
|
// This test demonstrates the bug where GetValue uses hardcoded index 0
|
||||||
// index in the vault's metadata. At index 0 it could not decrypt the secret,
|
// instead of the vault's actual derivation index when using environment mnemonic
|
||||||
// which was encrypted to the key at index 1.
|
|
||||||
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
// Set up test mnemonic
|
||||||
vaultDir := "/test-config/vaults.d/test-vault"
|
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
// Create temporary directory for vaults
|
||||||
defer mnemonic.Destroy()
|
fs := afero.NewOsFs()
|
||||||
|
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||||
vlt := &MockVault{
|
|
||||||
name: "test-vault",
|
|
||||||
fs: fs,
|
|
||||||
directory: vaultDir,
|
|
||||||
derivationIndex: 1,
|
|
||||||
mnemonic: mnemonic,
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
|
|
||||||
|
|
||||||
err = afero.WriteFile(
|
|
||||||
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
secretName, secretValue := "x", "value"
|
defer func() {
|
||||||
|
_ = fs.RemoveAll(tempDir)
|
||||||
|
}()
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName,
|
stateDir := filepath.Join(tempDir, ".secret")
|
||||||
memguard.NewBufferFromBytes([]byte(secretValue)), false)
|
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
|
// This test is now in the integration test file where it can use real vaults
|
||||||
require.NoError(t, err)
|
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
||||||
|
t.Log("This test demonstrates the bug in the integration test file")
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, secretValue, value.String())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||||
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
|
if err := s.fs.RemoveAll(s.Directory); err != nil {
|
||||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,26 +193,23 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
enrollmentDate := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s.%s-%s-%s",
|
"%s.%s-%s-%s",
|
||||||
seKeyLabelPrefix,
|
seKeyLabelPrefix,
|
||||||
vaultName,
|
vaultName,
|
||||||
hostname,
|
hostname,
|
||||||
enrollmentTime,
|
enrollmentDate,
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||||
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||||
// using ECIES. No intermediate age keypair is used.
|
// using ECIES. No intermediate age keypair is used.
|
||||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
|
||||||
// current unlocker, as getLongTermKeyForSE describes.
|
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
stateDir string,
|
stateDir string,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
if err := checkMacOSAvailable(); err != nil {
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -239,7 +236,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||||
|
|
||||||
// Step 2: Get the vault's long-term private key
|
// Step 2: Get the vault's long-term private key
|
||||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to get long-term private key: %w",
|
"failed to get long-term private key: %w",
|
||||||
@@ -257,7 +254,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Prepare the unlocker directory's path and metadata
|
// Step 4: Create unlocker directory and write files
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
@@ -265,7 +262,23 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
|
|
||||||
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to create unlocker directory: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SE-encrypted long-term key
|
||||||
|
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
|
||||||
|
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to write SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
@@ -281,25 +294,9 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
|
||||||
ltKeyPath := filepath.Join(dir, seLongtermFilename)
|
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
||||||
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"failed to write SE-encrypted long-term key: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
|
|
||||||
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SecureEnclaveUnlocker{
|
return &SecureEnclaveUnlocker{
|
||||||
@@ -309,15 +306,14 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
|
// getLongTermKeyForSE retrieves the vault's long-term private key
|
||||||
// from mnemonic when it is not nil, else through the current unlocker, which
|
// either from the mnemonic env var or by unlocking via the current unlocker.
|
||||||
// is given passphrase when it is a passphrase unlocker.
|
|
||||||
func getLongTermKeyForSE(
|
func getLongTermKeyForSE(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
vault VaultInterface,
|
vault VaultInterface,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
if mnemonic != nil {
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
|
if envMnemonic != "" {
|
||||||
// Read vault metadata to get the correct derivation index
|
// Read vault metadata to get the correct derivation index
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,7 +333,7 @@ func getLongTermKeyForSE(
|
|||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
// Use mnemonic with the vault's actual derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(
|
ltIdentity, err := agehd.DeriveIdentity(
|
||||||
mnemonic.String(),
|
envMnemonic,
|
||||||
metadata.DerivationIndex,
|
metadata.DerivationIndex,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,10 +352,6 @@ func getLongTermKeyForSE(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
|
||||||
passphraseUnlocker.Passphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,7 +80,6 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
_ afero.Fs,
|
_ afero.Fs,
|
||||||
_ string,
|
_ string,
|
||||||
_, _ *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
return nil, errSENotSupported
|
return nil, errSENotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
|
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||||
assert.Nil(t, unlocker)
|
assert.Nil(t, unlocker)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, errSENotSupported)
|
require.ErrorIs(t, err, errSENotSupported)
|
||||||
|
|||||||
+25
-56
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -132,10 +131,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save saves the version metadata and value. The files are written into a
|
// Save saves the version metadata and value
|
||||||
// temporary directory that is renamed to sv.Directory once all of them are
|
|
||||||
// complete, so the version directory is either whole or absent, even if the
|
|
||||||
// process dies part-way.
|
|
||||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return errNilValueBuffer
|
return errNilValueBuffer
|
||||||
@@ -149,22 +145,14 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
|
|
||||||
fs := sv.vault.GetFilesystem()
|
fs := sv.vault.GetFilesystem()
|
||||||
|
|
||||||
// Create the versions directory the finished version is renamed into
|
// Create version directory
|
||||||
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
|
err := fs.MkdirAll(sv.Directory, DirPerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
|
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create versions directory: %w", err)
|
return fmt.Errorf("failed to create version directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpDir, err := TempDirFor(fs, sv.Directory)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
|
||||||
defer func() { _ = fs.RemoveAll(tmpDir) }()
|
|
||||||
|
|
||||||
// Generate a new keypair for this version
|
// Generate a new keypair for this version
|
||||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||||
|
|
||||||
@@ -185,28 +173,21 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||||
)
|
)
|
||||||
|
|
||||||
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
|
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
|
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
|
err = sv.writeEncryptedMetadata(fs, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = fs.Rename(tmpDir, sv.Directory)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to move version into place: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Successfully saved secret version",
|
Debug("Successfully saved secret version",
|
||||||
"version", sv.Version, "secret_name", sv.SecretName)
|
"version", sv.Version, "secret_name", sv.SecretName)
|
||||||
|
|
||||||
@@ -377,18 +358,17 @@ func (sv *Version) GetValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writePublicKeyAndValue stores the version's public key and the value
|
// writePublicKeyAndValue stores the version's public key and the value
|
||||||
// encrypted to it in dir.
|
// encrypted to it.
|
||||||
func (sv *Version) writePublicKeyAndValue(
|
func (sv *Version) writePublicKeyAndValue(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
dir string,
|
|
||||||
versionIdentity *age.X25519Identity,
|
versionIdentity *age.X25519Identity,
|
||||||
value *memguard.LockedBuffer,
|
value *memguard.LockedBuffer,
|
||||||
) error {
|
) error {
|
||||||
versionPublicKey := versionIdentity.Recipient().String()
|
versionPublicKey := versionIdentity.Recipient().String()
|
||||||
pubKeyPath := filepath.Join(dir, "pub.age")
|
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||||
Debug("Writing version public key", "path", pubKeyPath)
|
Debug("Writing version public key", "path", pubKeyPath)
|
||||||
|
|
||||||
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
|
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||||
|
|
||||||
@@ -405,10 +385,10 @@ func (sv *Version) writePublicKeyAndValue(
|
|||||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
valuePath := filepath.Join(dir, "value.age")
|
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||||
Debug("Writing encrypted version value", "path", valuePath)
|
Debug("Writing encrypted version value", "path", valuePath)
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, valuePath, encryptedValue)
|
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||||
|
|
||||||
@@ -419,10 +399,9 @@ func (sv *Version) writePublicKeyAndValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeEncryptedPrivateKey encrypts the version's private key to the
|
// writeEncryptedPrivateKey encrypts the version's private key to the
|
||||||
// vault's long-term public key and stores it in dir.
|
// vault's long-term public key and stores it.
|
||||||
func (sv *Version) writeEncryptedPrivateKey(
|
func (sv *Version) writeEncryptedPrivateKey(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
dir string,
|
|
||||||
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
||||||
) error {
|
) error {
|
||||||
vaultDir, _ := sv.vault.GetDirectory()
|
vaultDir, _ := sv.vault.GetDirectory()
|
||||||
@@ -456,10 +435,10 @@ func (sv *Version) writeEncryptedPrivateKey(
|
|||||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
privKeyPath := filepath.Join(dir, "priv.age")
|
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to write encrypted version private key",
|
Debug("Failed to write encrypted version private key",
|
||||||
"error", err, "path", privKeyPath)
|
"error", err, "path", privKeyPath)
|
||||||
@@ -471,10 +450,9 @@ func (sv *Version) writeEncryptedPrivateKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeEncryptedMetadata encrypts the version metadata to the version's
|
// writeEncryptedMetadata encrypts the version metadata to the version's
|
||||||
// public key and stores it in dir.
|
// public key and stores it.
|
||||||
func (sv *Version) writeEncryptedMetadata(
|
func (sv *Version) writeEncryptedMetadata(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
dir string,
|
|
||||||
versionIdentity *age.X25519Identity,
|
versionIdentity *age.X25519Identity,
|
||||||
) error {
|
) error {
|
||||||
Debug("Encrypting version metadata", "version", sv.Version)
|
Debug("Encrypting version metadata", "version", sv.Version)
|
||||||
@@ -498,10 +476,10 @@ func (sv *Version) writeEncryptedMetadata(
|
|||||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(dir, "metadata.age")
|
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to write encrypted version metadata",
|
Debug("Failed to write encrypted version metadata",
|
||||||
"error", err, "path", metadataPath)
|
"error", err, "path", metadataPath)
|
||||||
@@ -546,18 +524,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
return versions, nil
|
return versions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionExists reports whether version is one of the versions ListVersions
|
|
||||||
// lists for the secret in secretDir. It only compares names, so a version
|
|
||||||
// the user typed can be checked with it before any path is built from it.
|
|
||||||
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
|
|
||||||
versions, err := ListVersions(fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices.Contains(versions, version), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentVersion returns the version that the "current" file points to
|
// GetCurrentVersion returns the version that the "current" file points to
|
||||||
// The file contains just the version name (e.g., "20231215.001")
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||||
@@ -574,12 +540,15 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentVersion updates the "current" file to point to a specific version
|
// SetCurrentVersion updates the "current" file to point to a specific version
|
||||||
// The file contains just the version name (e.g., "20231215.001"). It is
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
// replaced in one rename, so once written it always exists.
|
|
||||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
err := WriteFileAtomic(fs, currentPath, []byte(version))
|
// Remove existing file if it exists
|
||||||
|
_ = fs.Remove(currentPath)
|
||||||
|
|
||||||
|
// Write just the version name to the file
|
||||||
|
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create current version file: %w", err)
|
return fmt.Errorf("failed to create current version file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,14 +87,6 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
||||||
return nil, errNotImplementedInMock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
|
|||||||
@@ -17,20 +17,15 @@ var (
|
|||||||
"derived public key does not match vault: mnemonic may be incorrect",
|
"derived public key does not match vault: mnemonic may be incorrect",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidVaultName indicates a vault name that breaks the naming
|
// ErrInvalidVaultName indicates a vault name that does not match the
|
||||||
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
|
// allowed pattern [a-z0-9.\-_]+. Composed as
|
||||||
// empty, "." or "..". Composed by ValidateVaultName as
|
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
|
||||||
// "invalid vault name '<name>': <the rule>".
|
|
||||||
ErrInvalidVaultName = errors.New("invalid vault name")
|
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||||
|
|
||||||
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||||
// as "vault <name> does not exist".
|
// as "vault <name> does not exist".
|
||||||
ErrVaultNotFound = errors.New("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 indicates a nil value buffer was supplied.
|
||||||
ErrNilValueBuffer = errors.New("value buffer is nil")
|
ErrNilValueBuffer = errors.New("value buffer is nil")
|
||||||
|
|
||||||
@@ -54,7 +49,7 @@ var (
|
|||||||
|
|
||||||
// ErrVersionNotFound indicates the requested secret version does not
|
// ErrVersionNotFound indicates the requested secret version does not
|
||||||
// exist. Composed as
|
// exist. Composed as
|
||||||
// "version '<version>' not found for secret '<name>'".
|
// "version <version> not found for secret <name>".
|
||||||
ErrVersionNotFound = errors.New("not found for secret")
|
ErrVersionNotFound = errors.New("not found for secret")
|
||||||
|
|
||||||
// ErrNoVersions indicates the source secret has no versions. Composed
|
// ErrNoVersions indicates the source secret has no versions. Composed
|
||||||
@@ -68,10 +63,4 @@ var (
|
|||||||
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
|
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
|
||||||
// Composed as "unlocker with ID <id> not found".
|
// Composed as "unlocker with ID <id> not found".
|
||||||
ErrUnlockerNotFound = errors.New("not found")
|
ErrUnlockerNotFound = errors.New("not found")
|
||||||
|
|
||||||
// ErrNoLockForFilesystem indicates LockStateDir was given a filesystem
|
|
||||||
// it cannot lock. Composed as "cannot lock the state directory on
|
|
||||||
// filesystem <type>".
|
|
||||||
ErrNoLockForFilesystem = errors.New(
|
|
||||||
"cannot lock the state directory on filesystem")
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -40,49 +40,46 @@ func deriveVaultIdentity(
|
|||||||
return ltIdentity
|
return ltIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create a temporary directory for our tests
|
// Create a temporary directory for our tests
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
|
// Set test environment variables
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Test currentvault file handling (plain file with relative path)
|
// Test currentvault file handling (plain file with relative path)
|
||||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testCurrentVaultFileHandling(t, fs, tempDir)
|
testCurrentVaultFileHandling(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test secret operations with deeply nested paths
|
// Test secret operations with deeply nested paths
|
||||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testDeepPathSecrets(t, fs, tempDir)
|
testDeepPathSecrets(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test key caching in GetOrDeriveLongTermKey
|
// Test key caching in GetOrDeriveLongTermKey
|
||||||
t.Run("KeyCaching", func(t *testing.T) {
|
t.Run("KeyCaching", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testKeyCaching(t, fs, tempDir)
|
testKeyCaching(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test vault name validation
|
// Test vault name validation
|
||||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testVaultNameValidation(t, fs, tempDir)
|
testVaultNameValidation(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test multiple vaults and switching between them
|
// Test multiple vaults and switching between them
|
||||||
t.Run("MultipleVaults", func(t *testing.T) {
|
t.Run("MultipleVaults", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testMultipleVaults(t, fs, tempDir)
|
testMultipleVaults(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test adding a secret in one vault and verifying it's not visible in
|
// Test adding a secret in one vault and verifying it's not visible in
|
||||||
// another
|
// another
|
||||||
t.Run("VaultIsolation", func(t *testing.T) {
|
t.Run("VaultIsolation", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testVaultIsolation(t, fs, tempDir)
|
testVaultIsolation(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -98,8 +95,7 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault
|
// Create a test vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -144,10 +140,9 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault now handles public key when
|
||||||
// the mnemonic
|
// mnemonic is in env
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -202,11 +197,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||||
}
|
}
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||||
expectedValue, retrievedValue.Bytes())
|
string(expectedValue), string(retrievedValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,10 +214,9 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault now handles public key when
|
||||||
// the mnemonic
|
// mnemonic is in env
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -324,7 +317,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range validNames {
|
for _, name := range validNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -340,7 +333,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Expected error creating vault with invalid name %q, "+
|
t.Errorf("Expected error creating vault with invalid name %q, "+
|
||||||
"but got none", name)
|
"but got none", name)
|
||||||
@@ -361,7 +354,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create three vaults
|
// Create three vaults
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -409,14 +402,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create two vaults - CreateVault writes the public key derived from
|
// Create two vaults - CreateVault now handles public key when mnemonic
|
||||||
// the mnemonic
|
// is in env
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ var errUnexpectedValue = errors.New("unexpected value")
|
|||||||
|
|
||||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // the subtests are steps that build on each other
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Set mnemonic for testing
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vault, err := CreateVault(fs, testStateDir, "test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -116,10 +119,7 @@ func testCreateInitialVersion(
|
|||||||
// Verify secret can be retrieved
|
// Verify secret can be retrieved
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify version directory structure
|
// Verify version directory structure
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
@@ -166,10 +166,7 @@ func testCreateSecondVersion(
|
|||||||
// Verify new value is current
|
// Verify new value is current
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify we now have two versions
|
// Verify we now have two versions
|
||||||
versions, err = secret.ListVersions(fs, secretDir)
|
versions, err = secret.ListVersions(fs, secretDir)
|
||||||
@@ -212,10 +209,7 @@ func testCreateThirdVersion(
|
|||||||
// Current should be version-3
|
// Current should be version-3
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-3-data"), value.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRetrieveSpecificVersions(
|
func testRetrieveSpecificVersions(
|
||||||
@@ -231,29 +225,20 @@ func testRetrieveSpecificVersions(
|
|||||||
// Get each version by its name
|
// Get each version by its name
|
||||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value1)
|
||||||
defer value1.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
|
|
||||||
|
|
||||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value2)
|
||||||
defer value2.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
|
|
||||||
|
|
||||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value3)
|
||||||
|
|
||||||
defer value3.Destroy()
|
// Empty version should return current
|
||||||
|
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||||
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
|
||||||
// current one
|
|
||||||
_, err = vault.GetSecretVersion(secretName, "")
|
|
||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPromoteOldVersion(
|
func testPromoteOldVersion(
|
||||||
@@ -274,10 +259,7 @@ func testPromoteOldVersion(
|
|||||||
// Verify current now returns the old version's value
|
// Verify current now returns the old version's value
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
||||||
|
|
||||||
// Verify the version metadata hasn't changed
|
// Verify the version metadata hasn't changed
|
||||||
// (promoting shouldn't modify timestamps)
|
// (promoting shouldn't modify timestamps)
|
||||||
@@ -348,9 +330,9 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionConcurrency tests concurrent version operations
|
// TestVersionConcurrency tests concurrent version operations
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVersionConcurrency(t *testing.T) {
|
func TestVersionConcurrency(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
@@ -363,8 +345,6 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
|
|
||||||
// Test concurrent reads
|
// Test concurrent reads
|
||||||
t.Run("concurrent_reads", func(t *testing.T) {
|
t.Run("concurrent_reads", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
errCh := make(chan error, 10)
|
errCh := make(chan error, 10)
|
||||||
|
|
||||||
@@ -373,13 +353,8 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
} else {
|
} else if string(value) != "initial" {
|
||||||
if value.String() != "initial" {
|
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
|
||||||
errCh <- fmt.Errorf("%w: %s",
|
|
||||||
errUnexpectedValue, value.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
value.Destroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
done <- true
|
done <- true
|
||||||
@@ -402,9 +377,9 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionCompatibility tests that old secrets without versions still work
|
// TestVersionCompatibility tests that old secrets without versions still work
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVersionCompatibility(t *testing.T) {
|
func TestVersionCompatibility(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package vault
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
|
||||||
const lockFileName = "lock"
|
|
||||||
|
|
||||||
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
|
||||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // must outlive the call that takes it
|
|
||||||
var memFsLock sync.Mutex
|
|
||||||
|
|
||||||
// LockStateDir takes the lock that a command changing anything under
|
|
||||||
// stateDir holds until it returns, and returns the function that releases
|
|
||||||
// it. While one command holds it, the next one waits here. Reads take no
|
|
||||||
// lock: each file or directory a command changes is replaced in a single
|
|
||||||
// rename, so a reader finds it as it was before or after, never half-made.
|
|
||||||
//
|
|
||||||
// On the real filesystem the lock is flock(2) on the file "lock" in
|
|
||||||
// stateDir, which the kernel releases when the process dies, so a killed
|
|
||||||
// command never leaves the tool locked. The in-memory filesystem the tests
|
|
||||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
|
||||||
// Any other filesystem is refused rather than left unlocked.
|
|
||||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
|
||||||
switch fs.(type) {
|
|
||||||
case *afero.OsFs:
|
|
||||||
return flockStateDir(stateDir)
|
|
||||||
case *afero.MemMapFs:
|
|
||||||
memFsLock.Lock()
|
|
||||||
|
|
||||||
return memFsLock.Unlock, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
|
||||||
// directory and the file if needed. Go opens files close-on-exec, so
|
|
||||||
// programs the command runs, such as gpg, do not inherit the lock.
|
|
||||||
func flockStateDir(stateDir string) (func(), error) {
|
|
||||||
err := os.MkdirAll(stateDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create state directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lockPath := filepath.Join(stateDir, lockFileName)
|
|
||||||
|
|
||||||
//nolint:gosec // G304: the path is the lock file in the state directory
|
|
||||||
file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open lock file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to lock %s: %w", lockPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closing the file releases the lock.
|
|
||||||
return func() { _ = file.Close() }, nil
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package vault_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// lockWait is how long a test waits for the lock before deciding it
|
|
||||||
// will never come free.
|
|
||||||
lockWait = 10 * time.Second
|
|
||||||
|
|
||||||
// heldWait is how long a test watches a second holder fail to take a
|
|
||||||
// lock that is held. Broken exclusion lets it in at once.
|
|
||||||
heldWait = 100 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
// lockFilesystem is a filesystem LockStateDir can lock, with a state
|
|
||||||
// directory on it.
|
|
||||||
type lockFilesystem struct {
|
|
||||||
name string
|
|
||||||
fs afero.Fs
|
|
||||||
stateDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockFilesystems returns the real filesystem, locked with flock, and the
|
|
||||||
// in-memory one, locked with a mutex.
|
|
||||||
func lockFilesystems(t *testing.T) []lockFilesystem {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return []lockFilesystem{
|
|
||||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockInBackground starts taking the lock and returns a channel that
|
|
||||||
// delivers the function releasing it once it has been taken.
|
|
||||||
func lockInBackground(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir string,
|
|
||||||
) <-chan func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
taken := make(chan func(), 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
release, err := vault.LockStateDir(fs, stateDir)
|
|
||||||
if assert.NoError(t, err) {
|
|
||||||
taken <- release
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return taken
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirExcludes checks that while the lock is held a second
|
|
||||||
// holder, with its own open lock file on the real filesystem, waits, and
|
|
||||||
// that it gets the lock once the first releases it.
|
|
||||||
func TestLockStateDirExcludes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, lfs := range lockFilesystems(t) {
|
|
||||||
t.Run(lfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
taken := lockInBackground(t, lfs.fs, lfs.stateDir)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case second := <-taken:
|
|
||||||
second()
|
|
||||||
release()
|
|
||||||
t.Fatal("a second holder took the lock while it was held")
|
|
||||||
case <-time.After(heldWait):
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case second := <-taken:
|
|
||||||
second()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the second holder never got the lock")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirFreeAfterPanic checks that a holder that panics, and
|
|
||||||
// releases the lock with defer as every command does, leaves it free.
|
|
||||||
func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, lfs := range lockFilesystems(t) {
|
|
||||||
t.Run(lfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Panics(t, func() {
|
|
||||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
panic("the command failed")
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
|
||||||
case release := <-lockInBackground(t, lfs.fs, lfs.stateDir):
|
|
||||||
release()
|
|
||||||
case <-time.After(lockWait):
|
|
||||||
t.Fatal("the lock was still held after its holder panicked")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
|
||||||
// lock implementation is refused instead of being used unlocked.
|
|
||||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
|
||||||
require.ErrorIs(t, err, vault.ErrNoLockForFilesystem)
|
|
||||||
assert.Nil(t, release)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,12 +24,10 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidVaultName reports whether name is a valid vault name: only
|
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
||||||
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
|
// Note: We don't allow slashes in vault names unlike secret names
|
||||||
// "..". With no path separator allowed, a vault is always one directory
|
|
||||||
// directly under vaults.d.
|
|
||||||
func isValidVaultName(name string) bool {
|
func isValidVaultName(name string) bool {
|
||||||
if name == "" || name == "." || name == ".." {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,21 +36,6 @@ func isValidVaultName(name string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
|
|
||||||
// is not a valid vault name. Call it on the name exactly as the user gave it,
|
|
||||||
// before building any path from it.
|
|
||||||
func ValidateVaultName(name string) error {
|
|
||||||
if !isValidVaultName(name) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
|
|
||||||
"are allowed, and a name must not be empty, '.' or '..'",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// The file contains just the vault name (e.g., "default")
|
// The file contains just the vault name (e.g., "default")
|
||||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
@@ -155,18 +138,19 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
// It returns the derivation index, public key hash, and family hash.
|
// It returns the derivation index, public key hash, and family hash.
|
||||||
func processMnemonicForVault(
|
func processMnemonicForVault(
|
||||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||||
mnemonicBuffer *memguard.LockedBuffer,
|
|
||||||
) (uint32, string, string, error) {
|
) (uint32, string, string, error) {
|
||||||
if mnemonicBuffer == nil {
|
// Check if mnemonic is available in environment
|
||||||
secret.Debug("No mnemonic given, vault created without long-term key",
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
|
||||||
|
if mnemonic == "" {
|
||||||
|
secret.Debug("No mnemonic in environment, vault created without long-term key",
|
||||||
"vault", vaultName)
|
"vault", vaultName)
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return 0, "", "", nil
|
return 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := mnemonicBuffer.String()
|
secret.Debug("Mnemonic found in environment, deriving long-term key",
|
||||||
|
"vault", vaultName)
|
||||||
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
|
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
@@ -185,7 +169,7 @@ func processMnemonicForVault(
|
|||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -207,44 +191,28 @@ func processMnemonicForVault(
|
|||||||
return derivationIndex, publicKeyHash, familyHash, nil
|
return derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault and selects it as the current vault. When
|
// CreateVault creates a new vault
|
||||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
|
||||||
// long-term key until one is imported. It refuses a vault that already
|
|
||||||
// exists before writing anything: creating it again would replace its keys,
|
|
||||||
// and its secrets could no longer be decrypted. The commands that call it
|
|
||||||
// hold the state directory lock, so no other command can create the vault
|
|
||||||
// between the check and the writes.
|
|
||||||
func CreateVault(
|
|
||||||
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*Vault, error) {
|
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
err := ValidateVaultName(name)
|
// Validate vault name
|
||||||
if err != nil {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create vault directory structure
|
// Create vault directory structure
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Create main vault directory
|
// Create main vault directory
|
||||||
err = fs.MkdirAll(vaultDir, secret.DirPerms)
|
err := fs.MkdirAll(vaultDir, secret.DirPerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -267,7 +235,7 @@ func CreateVault(
|
|||||||
|
|
||||||
// Process mnemonic if available
|
// Process mnemonic if available
|
||||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
||||||
fs, stateDir, vaultDir, name, mnemonic)
|
fs, stateDir, vaultDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -296,21 +264,21 @@ func CreateVault(
|
|||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
secret.Debug("Successfully created vault", "name", name)
|
secret.Debug("Successfully created vault", "name", name)
|
||||||
|
|
||||||
vlt := NewVault(fs, stateDir, name)
|
return NewVault(fs, stateDir, name), nil
|
||||||
vlt.Mnemonic = mnemonic
|
|
||||||
|
|
||||||
return vlt, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectVault selects the given vault as the current vault
|
// SelectVault selects the given vault as the current vault
|
||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
err := ValidateVaultName(name)
|
// Validate vault name
|
||||||
if err != nil {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return err
|
return fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
@@ -327,13 +295,21 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
|||||||
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or replace the currentvault file with just the vault name. It
|
// Create or update the currentvault file with just the vault name
|
||||||
// is replaced in one rename, so it never goes missing.
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
|
// Remove existing file if it exists
|
||||||
|
_, err = fs.Stat(currentVaultPath)
|
||||||
|
if err == nil {
|
||||||
|
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
||||||
|
|
||||||
|
_ = fs.Remove(currentVaultPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write just the vault name to the file
|
||||||
secret.Debug("Writing currentvault file", "vault_name", name)
|
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
|
err = afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to select vault: %w", err)
|
return fmt.Errorf("failed to select vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
|||||||
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
|
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write vault metadata: %w", err)
|
return fmt.Errorf("failed to write vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkflowMismatch(t *testing.T) {
|
func TestWorkflowMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create a temporary directory for testing
|
// Create a temporary directory for testing
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, tempDir, "default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
@@ -321,15 +321,19 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
_, err = vault.CreateVault(fs, tempDir, "work", nil)
|
t.Setenv("SB_SECRET_MNEMONIC", "")
|
||||||
|
|
||||||
|
_, err = vault.CreateVault(fs, tempDir, "work")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
|
|
||||||
// Simulate the vault import process: get the next available derivation
|
// Simulate the vault import process
|
||||||
// index for this mnemonic
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
|
|
||||||
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -12,13 +13,15 @@ import (
|
|||||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||||
// validates the secret name and rejects path traversal attempts.
|
// validates the secret name and rejects path traversal attempts.
|
||||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -38,8 +41,6 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
require.Error(t, err,
|
require.Error(t, err,
|
||||||
"GetSecretVersion should reject malicious name: %s", name)
|
"GetSecretVersion should reject malicious name: %s", name)
|
||||||
@@ -52,12 +53,12 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
||||||
// GetSecretVersion) also rejects path traversal names.
|
// GetSecretVersion) also rejects path traversal names.
|
||||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
@@ -67,13 +68,15 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
// also validates names and rejects path traversal attempts.
|
// also validates names and rejects path traversal attempts.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
@@ -84,8 +87,6 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := vlt.GetSecretObject(name)
|
_, err := vlt.GetSecretObject(name)
|
||||||
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||||
require.Contains(t, err.Error(), "invalid secret name")
|
require.Contains(t, err.Error(), "invalid secret name")
|
||||||
|
|||||||
+125
-143
@@ -171,59 +171,17 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
slog.String("secret_dir", secretDir),
|
slog.String("secret_dir", secretDir),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for an existing secret and the version the new one supersedes
|
// Check for an existing secret and prepare its directory
|
||||||
exists, previousVersion, err := v.checkExistingSecret(name, secretDir, force)
|
exists, previousVersion, err := v.prepareSecretDir(name, secretDir, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
|
||||||
return v.addVersion(name, secretDir, value, previousVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.addNewSecret(name, secretDir, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addNewSecret creates a secret by assembling its first version and current
|
|
||||||
// pointer in a temporary directory, then renaming that directory to
|
|
||||||
// secretDir, so an interrupted add leaves no half-made secret behind.
|
|
||||||
func (v *Vault) addNewSecret(
|
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
|
||||||
) error {
|
|
||||||
buildDir, err := secret.TempDirFor(v.fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
|
||||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
|
||||||
|
|
||||||
err = v.addVersion(name, buildDir, value, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.fs.Rename(buildDir, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move new secret into place: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addVersion saves value as a new version under secretDir, sets the
|
|
||||||
// notAfter timestamp of the version it supersedes, if any, and then points
|
|
||||||
// current at the new version. Until that last step, current still names the
|
|
||||||
// previous version, which stays readable.
|
|
||||||
func (v *Vault) addVersion(
|
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
|
||||||
previousVersion *secret.Version,
|
|
||||||
) error {
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Create the new version and save the encrypted value
|
// Create the new version and save the encrypted value
|
||||||
versionName, err := v.createAndSaveVersion(
|
versionName, err := v.createAndSaveVersion(
|
||||||
name, secretDir, value, previousVersion, &now)
|
name, secretDir, value, previousVersion, &now, exists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -293,7 +251,7 @@ func updateVersionMetadata(
|
|||||||
// Write encrypted metadata
|
// Write encrypted metadata
|
||||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -301,44 +259,27 @@ func updateVersionMetadata(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret retrieves the current version of a secret from this vault.
|
// GetSecret retrieves a secret from this vault
|
||||||
// The caller must destroy the returned buffer.
|
func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||||
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
|
|
||||||
secret.DebugWith("Getting secret from vault",
|
secret.DebugWith("Getting secret from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSecretObject validates the name and checks that the secret exists
|
return v.GetSecretVersion(name, "")
|
||||||
secretObj, err := v.GetSecretObject(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretObj.Directory)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.GetSecretVersion(name, currentVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretVersion retrieves a specific version of a secret. The version
|
// GetSecretVersion retrieves a specific version of a secret (empty version
|
||||||
// must be one of the secret's versions; GetSecret gets the current one.
|
// means current)
|
||||||
// The caller must destroy the returned buffer.
|
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||||
func (v *Vault) GetSecretVersion(
|
|
||||||
name string, version string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
secret.DebugWith("Getting secret version from vault",
|
secret.DebugWith("Getting secret version from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate the name and check that the version exists
|
// Validate the name and resolve the version to fetch
|
||||||
err := v.checkSecretVersion(name, version)
|
version, err := v.resolveSecretVersion(name, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -376,14 +317,26 @@ func (v *Vault) GetSecretVersion(
|
|||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a copy to return since the buffer will be destroyed
|
||||||
|
result := make([]byte, decryptedValue.Size())
|
||||||
|
copy(result, decryptedValue.Bytes())
|
||||||
|
decryptedValue.Destroy()
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted secret version",
|
secret.DebugWith("Successfully decrypted secret version",
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.Int("decrypted_length", decryptedValue.Size()),
|
slog.Int("decrypted_length", len(result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return decryptedValue, nil
|
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
||||||
|
secret.Debug("Vault secret decryption debug info",
|
||||||
|
"secret_name", name,
|
||||||
|
"version", version,
|
||||||
|
"decrypted_value_length", len(result),
|
||||||
|
"is_empty", len(result) == 0)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockVault unlocks the vault and returns the long-term private key
|
// UnlockVault unlocks the vault and returns the long-term private key
|
||||||
@@ -457,14 +410,12 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
return secretObj, nil
|
return secretObj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopySecretVersion copies a single version from source into destSecretDir
|
// CopySecretVersion copies a single version from source to this vault
|
||||||
// in this vault. It decrypts the value using srcIdentity and re-encrypts
|
// It decrypts the value using srcIdentity and re-encrypts for this vault
|
||||||
// for this vault.
|
|
||||||
func (v *Vault) CopySecretVersion(
|
func (v *Vault) CopySecretVersion(
|
||||||
srcVersion *secret.Version,
|
srcVersion *secret.Version,
|
||||||
srcIdentity *age.X25519Identity,
|
srcIdentity *age.X25519Identity,
|
||||||
destSecretName string,
|
destSecretName string,
|
||||||
destSecretDir string,
|
|
||||||
destVersionName string,
|
destVersionName string,
|
||||||
) error {
|
) error {
|
||||||
secret.DebugWith("Copying secret version to vault",
|
secret.DebugWith("Copying secret version to vault",
|
||||||
@@ -490,7 +441,6 @@ func (v *Vault) CopySecretVersion(
|
|||||||
|
|
||||||
// Create destination version with same name
|
// Create destination version with same name
|
||||||
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
||||||
destVersion.Directory = filepath.Join(destSecretDir, "versions", destVersionName)
|
|
||||||
|
|
||||||
// Copy metadata (preserve original timestamps)
|
// Copy metadata (preserve original timestamps)
|
||||||
destVersion.Metadata = srcVersion.Metadata
|
destVersion.Metadata = srcVersion.Metadata
|
||||||
@@ -531,11 +481,11 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refuse to replace an existing destination secret unless forced
|
// Check if destination secret already exists and clear it if forced
|
||||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||||
|
|
||||||
err = v.checkCopyDestination(destSecretDir, destSecretName, force)
|
err = v.prepareCopyDestination(destSecretDir, destSecretName, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -571,8 +521,14 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return fmt.Errorf("failed to get current version: %w", err)
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy each version and the current pointer, then move the copy into place
|
// Create destination secret directory
|
||||||
err = v.copyVersions(srcVault, srcIdentity,
|
err = v.fs.MkdirAll(destSecretDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy each version and set the current pointer, rolling back on error
|
||||||
|
err = v.copyVersionsWithRollback(srcVault, srcIdentity,
|
||||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -587,10 +543,10 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkExistingSecret reports whether the secret already exists, refuses to
|
// prepareSecretDir checks for an existing secret directory and prepares it
|
||||||
// overwrite it unless force is set, and returns its current version, which
|
// for a new version. It returns whether the secret already existed and the
|
||||||
// the new version supersedes, if any.
|
// current version to be superseded, if any.
|
||||||
func (v *Vault) checkExistingSecret(
|
func (v *Vault) prepareSecretDir(
|
||||||
name, secretDir string, force bool,
|
name, secretDir string, force bool,
|
||||||
) (bool, *secret.Version, error) {
|
) (bool, *secret.Version, error) {
|
||||||
// Check if secret already exists
|
// Check if secret already exists
|
||||||
@@ -607,6 +563,19 @@ func (v *Vault) checkExistingSecret(
|
|||||||
secret.Debug("Secret existence check complete", "exists", exists)
|
secret.Debug("Secret existence check complete", "exists", exists)
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
// Create secret directory for new secret
|
||||||
|
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||||
|
|
||||||
|
err = v.fs.MkdirAll(secretDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create secret directory",
|
||||||
|
"error", err, "secret_dir", secretDir)
|
||||||
|
|
||||||
|
return false, nil, fmt.Errorf("failed to create secret directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Created secret directory successfully")
|
||||||
|
|
||||||
return false, nil, nil
|
return false, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,15 +640,15 @@ func (v *Vault) updatePreviousVersion(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSecretVersion validates the secret name and verifies that the secret
|
// resolveSecretVersion validates the secret name, verifies the secret and
|
||||||
// exists and that version is one of its versions.
|
// version exist, and resolves an empty version to the current one.
|
||||||
func (v *Vault) checkSecretVersion(name, version string) error {
|
func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
|
||||||
// Validate secret name to prevent path traversal
|
// Validate secret name to prevent path traversal
|
||||||
err := ValidateSecretName(name)
|
err := ValidateSecretName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory
|
// Get vault directory
|
||||||
@@ -687,7 +656,7 @@ func (v *Vault) checkSecretVersion(name, version string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert slashes to percent signs for storage
|
// Convert slashes to percent signs for storage
|
||||||
@@ -699,38 +668,58 @@ func (v *Vault) checkSecretVersion(name, version string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return "", fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||||
|
|
||||||
return fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which version to get
|
||||||
|
if version == "" {
|
||||||
|
// Get current version
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version = currentVersion
|
||||||
|
|
||||||
|
secret.Debug("Using current version", "version", version, "secret_name", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
exists, err = secret.VersionExists(v.fs, secretDir, version)
|
versionPath := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(v.fs, versionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return "", fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
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 nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndSaveVersion generates a new version name, sets the version
|
// createAndSaveVersion generates a new version name, sets the version
|
||||||
// timestamps, and saves the encrypted value under secretDir, which is a
|
// timestamps, and saves the encrypted value. When saving fails for a newly
|
||||||
// temporary directory while a new secret is being assembled.
|
// created secret, the secret directory is removed again.
|
||||||
func (v *Vault) createAndSaveVersion(
|
func (v *Vault) createAndSaveVersion(
|
||||||
name, secretDir string, value *memguard.LockedBuffer,
|
name, secretDir string, value *memguard.LockedBuffer,
|
||||||
previousVersion *secret.Version, now *time.Time,
|
previousVersion *secret.Version, now *time.Time, exists bool,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
// Generate new version name
|
// Generate new version name
|
||||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||||
@@ -744,7 +733,6 @@ func (v *Vault) createAndSaveVersion(
|
|||||||
|
|
||||||
// Create new version
|
// Create new version
|
||||||
newVersion := secret.NewVersion(v, name, versionName)
|
newVersion := secret.NewVersion(v, name, versionName)
|
||||||
newVersion.Directory = filepath.Join(secretDir, "versions", versionName)
|
|
||||||
|
|
||||||
// Set version timestamps
|
// Set version timestamps
|
||||||
if previousVersion == nil {
|
if previousVersion == nil {
|
||||||
@@ -764,73 +752,57 @@ func (v *Vault) createAndSaveVersion(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||||
|
|
||||||
|
// Clean up the secret directory if this was a new secret
|
||||||
|
if !exists {
|
||||||
|
secret.Debug("Cleaning up secret directory due to save failure",
|
||||||
|
"secret_dir", secretDir)
|
||||||
|
|
||||||
|
_ = v.fs.RemoveAll(secretDir)
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to save version: %w", err)
|
return "", fmt.Errorf("failed to save version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return versionName, nil
|
return versionName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyVersions copies each version of the source secret and its current
|
// copyVersionsWithRollback copies each version of the source secret into the
|
||||||
// pointer into a temporary directory, then moves that directory to
|
// destination directory and sets the current version pointer, removing the
|
||||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
// partial copy when any step fails.
|
||||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
func (v *Vault) copyVersionsWithRollback(
|
||||||
// temporary directory behind.
|
|
||||||
func (v *Vault) copyVersions(
|
|
||||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||||
srcSecretName, destSecretName, destSecretDir string,
|
srcSecretName, destSecretName, destSecretDir string,
|
||||||
versions []string, currentVersion string,
|
versions []string, currentVersion string,
|
||||||
) error {
|
) error {
|
||||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
// Copy each version
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the rename below has moved it into place, this finds nothing.
|
|
||||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
|
||||||
|
|
||||||
for _, versionName := range versions {
|
for _, versionName := range versions {
|
||||||
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
||||||
|
|
||||||
err = v.CopySecretVersion(
|
err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName)
|
||||||
srcVersion, srcIdentity, destSecretName, buildDir, versionName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Rollback: remove partial copy
|
||||||
|
secret.Debug("Rolling back partial copy due to error", "error", err)
|
||||||
|
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
// Set current version
|
||||||
|
err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With --force, the secret being replaced goes only now that its
|
|
||||||
// replacement is complete
|
|
||||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check destination: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(v.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.fs.Rename(buildDir, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move copied secret into place: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
// prepareCopyDestination ensures the destination secret directory can be
|
||||||
// is set. A secret being replaced is removed by copyVersions, once its
|
// created, removing an existing secret when force is set.
|
||||||
// replacement is complete.
|
func (v *Vault) prepareCopyDestination(
|
||||||
func (v *Vault) checkCopyDestination(
|
|
||||||
destSecretDir, destSecretName string, force bool,
|
destSecretDir, destSecretName string, force bool,
|
||||||
) error {
|
) error {
|
||||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||||
@@ -845,5 +817,15 @@ func (v *Vault) checkCopyDestination(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exists && force {
|
||||||
|
// Remove existing secret
|
||||||
|
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
||||||
|
|
||||||
|
err = v.fs.RemoveAll(destSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ import (
|
|||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||||
"abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// envTestMnemonic is the (deliberately different) mnemonic placed in the
|
||||||
|
// environment; the vault is unlocked manually with the derived key in
|
||||||
|
// createTestVaultWithKey.
|
||||||
|
//
|
||||||
|
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
|
||||||
|
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon about"
|
||||||
|
|
||||||
// Shared fixtures for white-box tests in this package.
|
// Shared fixtures for white-box tests in this package.
|
||||||
const (
|
const (
|
||||||
testStateDir = "/test/state"
|
testStateDir = "/test/state"
|
||||||
@@ -65,8 +73,11 @@ func addTestSecretToVault(
|
|||||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Set mnemonic for testing
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
vault, err := CreateVault(fs, testStateDir, "test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -87,9 +98,8 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|||||||
return vault
|
return vault
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -121,15 +131,11 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
// Get the secret value
|
// Get the secret value
|
||||||
retrievedValue, err := vault.GetSecret(testSecretPath)
|
retrievedValue, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedValue, retrievedValue)
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -159,15 +165,11 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
// Current value should be version-2
|
// Current value should be version-2
|
||||||
value, err := vault.GetSecret(testSecretPath)
|
value, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultGetSecretVersion(t *testing.T) {
|
func TestVaultGetSecretVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -190,31 +192,24 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Get specific version (first one)
|
// Get specific version (first one)
|
||||||
firstVersion := versions[1] // Last in list is first created
|
firstVersion := versions[1] // Last in list is first created
|
||||||
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
defer first.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, []byte("version-1"), first.Bytes())
|
|
||||||
|
|
||||||
// Get specific version (second one)
|
// Get specific version (second one)
|
||||||
secondVersion := versions[0] // First in list is most recent
|
secondVersion := versions[0] // First in list is most recent
|
||||||
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
|
|
||||||
defer second.Destroy()
|
// Get current (empty version)
|
||||||
|
value, err = vault.GetSecretVersion(testSecretPath, "")
|
||||||
assert.Equal(t, []byte("version-2"), second.Bytes())
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
|
||||||
// current one
|
|
||||||
_, err = vault.GetSecretVersion(testSecretPath, "")
|
|
||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultVersionTimestamps(t *testing.T) {
|
func TestVaultVersionTimestamps(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -296,9 +291,8 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -313,9 +307,8 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestUpdateVersionMetadata(t *testing.T) {
|
func TestUpdateVersionMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
|
|||||||
+137
-186
@@ -2,10 +2,8 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -72,9 +70,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
secret.Debug("Creating passphrase unlocker instance",
|
secret.Debug("Creating passphrase unlocker instance",
|
||||||
"unlocker_type", metadata.Type)
|
"unlocker_type", metadata.Type)
|
||||||
|
|
||||||
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
|
||||||
unlocker = passphraseUnlocker
|
|
||||||
case "pgp":
|
case "pgp":
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
@@ -105,7 +101,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
|
|
||||||
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
||||||
// unlocker directory path
|
// unlocker directory path
|
||||||
// The file contains just the name of the unlocker's directory in unlockers.d
|
// The file contains just the unlocker name (e.g., "passphrase")
|
||||||
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||||
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
||||||
|
|
||||||
@@ -130,11 +126,7 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||||
// instance and its directory path. A directory that ListUnlockers skips is
|
// instance and its directory path
|
||||||
// skipped here too, with the same warning. Such a directory has no ID: if
|
|
||||||
// no unlocker has the ID unlockerID but such a directory is named
|
|
||||||
// unlockerID, that directory is returned with a nil unlocker, so that
|
|
||||||
// RemoveUnlocker can remove it.
|
|
||||||
//
|
//
|
||||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||||
func (v *Vault) findUnlockerByID(
|
func (v *Vault) findUnlockerByID(
|
||||||
@@ -145,24 +137,42 @@ func (v *Vault) findUnlockerByID(
|
|||||||
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
skippedDirPath := ""
|
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if !file.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
// Read metadata file
|
||||||
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
if !ok {
|
if err != nil {
|
||||||
if file.Name() == unlockerID {
|
return nil, "", fmt.Errorf(
|
||||||
skippedDirPath = unlockerDirPath
|
"failed to check if metadata exists for unlocker %s: %w",
|
||||||
}
|
file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Skip directories without metadata - they might not be unlockers
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
// Create the appropriate unlocker instance
|
||||||
var tempUnlocker secret.Unlocker
|
var tempUnlocker secret.Unlocker
|
||||||
|
|
||||||
@@ -185,7 +195,7 @@ func (v *Vault) findUnlockerByID(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, skippedDirPath, nil
|
return nil, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns a list of available unlockers for this vault
|
||||||
@@ -216,12 +226,39 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
var unlockers []UnlockerMetadata
|
var unlockers []UnlockerMetadata
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if file.IsDir() {
|
||||||
continue
|
// Read metadata file
|
||||||
}
|
metadataPath := filepath.Join(unlockersDir, file.Name(),
|
||||||
|
"unlocker-metadata.json")
|
||||||
|
|
||||||
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to check if metadata exists for unlocker %s: %w",
|
||||||
|
file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
secret.Warn("Skipping unlocker directory with missing metadata file",
|
||||||
|
"directory", file.Name())
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
|
||||||
if ok {
|
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers = append(unlockers, metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,54 +266,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return unlockers, nil
|
return unlockers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
|
// RemoveUnlocker removes an unlocker from this vault
|
||||||
// name in unlockersDir. If the metadata file cannot be checked for, is
|
|
||||||
// missing, or cannot be read or parsed, it warns, naming the directory,
|
|
||||||
// and returns false: the caller skips that directory.
|
|
||||||
func (v *Vault) readUnlockerMetadataOrWarn(
|
|
||||||
unlockersDir, name string,
|
|
||||||
) (UnlockerMetadata, bool) {
|
|
||||||
metadataPath := filepath.Join(unlockersDir, name, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
secret.Warn("Skipping unlocker directory with missing metadata file",
|
|
||||||
"directory", name)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
|
||||||
"directory", name, "error", err)
|
|
||||||
|
|
||||||
return metadata, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
|
||||||
// directory that ListUnlockers skips is removed by its directory name; its
|
|
||||||
// type is unknown, so only the directory is removed.
|
|
||||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -287,17 +277,13 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlockerDir == "" {
|
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the unlocker's Remove method
|
// Use the unlocker's Remove method
|
||||||
@@ -315,27 +301,39 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A directory found without an unlocker is one ListUnlockers skips; it
|
if targetUnlockerDir == "" {
|
||||||
// cannot be selected.
|
|
||||||
if unlocker == nil {
|
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or replace the current-unlocker file with just the unlocker
|
// Create/update current-unlocker file with just the unlocker name
|
||||||
// name. It is replaced in one rename, so it never goes missing.
|
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
|
// Remove existing file if it exists
|
||||||
|
exists, err := afero.Exists(v.fs, currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
err = v.fs.Remove(currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get just the unlocker name (basename of the directory)
|
// Get just the unlocker name (basename of the directory)
|
||||||
unlockerName := filepath.Base(targetUnlockerDir)
|
unlockerName := filepath.Base(targetUnlockerDir)
|
||||||
|
|
||||||
|
// Write just the unlocker name to the file
|
||||||
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath, []byte(unlockerName))
|
err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName),
|
||||||
|
secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -343,10 +341,7 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
// directory of its own, makes it the current unlocker, and only then removes
|
|
||||||
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
|
|
||||||
// point leaves a complete current unlocker, the old one or the new.
|
|
||||||
// The passphrase must be provided as a LockedBuffer for security
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
func (v *Vault) CreatePassphraseUnlocker(
|
func (v *Vault) CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer,
|
passphrase *memguard.LockedBuffer,
|
||||||
@@ -356,33 +351,53 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to get the long-term key (either from memory if unlocked, or
|
// Create unlocker directory
|
||||||
// derive it). Getting it before anything is written means failing to
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
|
||||||
// get it changes nothing.
|
|
||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
// The passphrase unlockers the new one replaces
|
|
||||||
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt := time.Now()
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
|
|
||||||
createdAt.UTC().Format(secret.UnlockerTimeFormat))
|
|
||||||
|
|
||||||
// Generate new age keypair for unlocker
|
// Generate new age keypair for unlocker
|
||||||
unlockerIdentity, err := age.GenerateX25519Identity()
|
unlockerIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write the unlocker keypair (public and passphrase-encrypted private)
|
||||||
|
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create metadata
|
||||||
|
metadata := UnlockerMetadata{
|
||||||
|
Type: unlockerTypePassphrase,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Flags: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
|
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt long-term private key to this unlocker
|
// Encrypt long-term private key to this unlocker
|
||||||
|
// We need to get the long-term key (either from memory if unlocked, or derive it)
|
||||||
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
defer ltPrivKeyBuffer.Destroy()
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
@@ -392,74 +407,23 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := UnlockerMetadata{
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
Type: unlockerTypePassphrase,
|
|
||||||
CreatedAt: createdAt,
|
|
||||||
Flags: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the unlocker's files, the metadata last
|
// Create the unlocker instance
|
||||||
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
|
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
|
|
||||||
encryptedLtPrivKey, metadataBytes)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the new unlocker by its directory, not by its ID: an old
|
// Select this unlocker as current
|
||||||
// passphrase unlocker created in the same minute has the same ID.
|
err = v.SelectUnlocker(unlocker.GetID())
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
|
|
||||||
[]byte(filepath.Base(unlockerDir)))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, oldDir := range oldDirs {
|
return unlocker, nil
|
||||||
err = secret.RemoveDirAtomic(v.fs, oldDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"created and selected the new passphrase unlocker: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
|
|
||||||
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
|
|
||||||
// same warning.
|
|
||||||
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
|
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dirs []string
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
|
||||||
if ok && metadata.Type == unlockerTypePassphrase {
|
|
||||||
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dirs, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
||||||
@@ -493,20 +457,19 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeUnlockerFiles writes the files of a passphrase unlocker into
|
// writeUnlockerKeypair writes the unlocker's public key and its
|
||||||
// unlockerDir: its public key, its passphrase-encrypted private key, the
|
// passphrase-encrypted private key into the unlocker directory.
|
||||||
// long-term private key encrypted to it, and its metadata, last.
|
func (v *Vault) writeUnlockerKeypair(
|
||||||
func (v *Vault) writeUnlockerFiles(
|
|
||||||
unlockerDir string,
|
unlockerDir string,
|
||||||
unlockerIdentity *age.X25519Identity,
|
unlockerIdentity *age.X25519Identity,
|
||||||
passphrase *memguard.LockedBuffer,
|
passphrase *memguard.LockedBuffer,
|
||||||
encryptedLtPrivKey, metadataBytes []byte,
|
|
||||||
) error {
|
) error {
|
||||||
// Write public key
|
// Write public key
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
|
err := afero.WriteFile(v.fs, pubKeyPath,
|
||||||
[]byte(unlockerIdentity.Recipient().String()))
|
[]byte(unlockerIdentity.Recipient().String()),
|
||||||
|
secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -525,22 +488,10 @@ func (v *Vault) writeUnlockerFiles(
|
|||||||
// Write encrypted private key
|
// Write encrypted private key
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
|
err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
|
||||||
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
|
||||||
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-32
@@ -3,12 +3,12 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,13 +18,6 @@ type Vault struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
||||||
// Mnemonic, when not nil, is what the long-term key is derived from
|
|
||||||
// instead of the current unlocker. The caller destroys it.
|
|
||||||
Mnemonic *memguard.LockedBuffer
|
|
||||||
// UnlockPassphrase, when not nil, is given to the current unlocker
|
|
||||||
// when that is a passphrase unlocker, which otherwise prompts for it.
|
|
||||||
// The caller destroys it.
|
|
||||||
UnlockPassphrase *memguard.LockedBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVault creates a new Vault instance
|
// NewVault creates a new Vault instance
|
||||||
@@ -63,18 +56,6 @@ func (v *Vault) ClearLongTermKey() {
|
|||||||
v.longTermKey = nil
|
v.longTermKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMnemonic sets v.Mnemonic, for code that has v only as a
|
|
||||||
// secret.VaultInterface.
|
|
||||||
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
|
||||||
v.Mnemonic = mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
|
|
||||||
// a secret.VaultInterface.
|
|
||||||
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
|
|
||||||
v.UnlockPassphrase = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||||
// from available sources
|
// from available sources
|
||||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
@@ -85,8 +66,9 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
||||||
|
|
||||||
if v.Mnemonic != nil {
|
// Try to derive from environment mnemonic first
|
||||||
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
|
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mnemonic available, try to use current unlocker
|
// No mnemonic available, try to use current unlocker
|
||||||
@@ -156,12 +138,7 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
|
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
|
|
||||||
secretsDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -185,7 +162,7 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
|
|
||||||
exists, err := afero.Exists(v.fs, currentFile)
|
exists, err := afero.Exists(v.fs, currentFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
|
continue // Skip directories we can't read
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
@@ -199,9 +176,9 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
||||||
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
||||||
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
||||||
mnemonic string,
|
envMnemonic string,
|
||||||
) (*age.X25519Identity, error) {
|
) (*age.X25519Identity, error) {
|
||||||
secret.Debug("Using mnemonic for long-term key derivation",
|
secret.Debug("Using mnemonic from environment for long-term key derivation",
|
||||||
"vault_name", v.Name)
|
"vault_name", v.Name)
|
||||||
|
|
||||||
// Load vault metadata to get the derivation index
|
// Load vault metadata to get the derivation index
|
||||||
@@ -217,7 +194,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
|
|||||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to derive long-term key from mnemonic",
|
secret.Debug("Failed to derive long-term key from mnemonic",
|
||||||
"error", err, "vault_name", v.Name)
|
"error", err, "vault_name", v.Name)
|
||||||
|
|||||||
@@ -90,10 +90,4 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
|||||||
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
|
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
|
||||||
exists, _ := afero.DirExists(fs, secretDir)
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
|
||||||
// Nor is the temporary directory the secret was assembled in left behind
|
|
||||||
entries, err := afero.ReadDir(fs, vaultDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.Equal(t, "pub.age", entries[0].Name())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -27,19 +26,12 @@ const (
|
|||||||
testPassphrase = "test-passphrase"
|
testPassphrase = "test-passphrase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
|
||||||
// destroyed when the test ends.
|
|
||||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
return mnemonic
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // order-dependent subtests forbid parallel
|
|
||||||
func TestVaultOperations(t *testing.T) {
|
func TestVaultOperations(t *testing.T) {
|
||||||
|
// Test environment will be cleaned up automatically by t.Setenv
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
@@ -71,8 +63,7 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
func testCreateVault(t *testing.T, fs afero.Fs) {
|
func testCreateVault(t *testing.T, fs afero.Fs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,11 +184,10 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get secret: %v", err)
|
t.Fatalf("Failed to get secret: %v", err)
|
||||||
}
|
}
|
||||||
defer retrievedValue.Destroy()
|
|
||||||
|
|
||||||
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
t.Errorf("Expected secret value '%s', got '%s'",
|
t.Errorf("Expected secret value '%s', got '%s'",
|
||||||
expectedValue, retrievedValue.Bytes())
|
string(expectedValue), string(retrievedValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +219,6 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test vault unlocking (should happen automatically via mnemonic)
|
// Test vault unlocking (should happen automatically via mnemonic)
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
if vlt.Locked() {
|
if vlt.Locked() {
|
||||||
_, err := vlt.UnlockVault()
|
_, err := vlt.UnlockVault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,14 +279,15 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
// Set test environment variables
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
testMnemonicBuffer(t))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -6,7 +6,6 @@
|
|||||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||||
# otherwise a pinned version is installed via nvm (installing nvm
|
# otherwise a pinned version is installed via nvm (installing nvm
|
||||||
# itself first, from a hash-verified release archive, never curl | sh).
|
# 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.
|
# Uncomment the language sections in main() that apply to this repo.
|
||||||
set -eu
|
set -eu
|
||||||
@@ -137,6 +136,12 @@ main() {
|
|||||||
|
|
||||||
# ---- Go repos ----
|
# ---- Go repos ----
|
||||||
if missing go; then pkg_install go golang go go; fi
|
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
|
go mod download
|
||||||
|
|
||||||
# ---- Python repos ----
|
# ---- Python repos ----
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
+1
-5
@@ -4,17 +4,13 @@
|
|||||||
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
|
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
|
||||||
# that lock large secrets in memory (memguard mlocks them) run; under the
|
# that lock large secrets in memory (memguard mlocks them) run; under the
|
||||||
# lower limit of a plain `docker build .` they are skipped.
|
# lower limit of a plain `docker build .` they are skipped.
|
||||||
# A cached build checks nothing: a new CHECK_EPOCH on every run makes the
|
|
||||||
# Dockerfile's check steps run again on an unchanged tree, while its base
|
|
||||||
# images and module downloads stay cached.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --ulimit memlock=-1:-1 \
|
docker build --ulimit memlock=-1:-1 .
|
||||||
--build-arg CHECK_EPOCH="$(date +%s)" .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+4
-14
@@ -1,24 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter, in docker only. Builds Dockerfile.lint,
|
# script/lint: run the linter.
|
||||||
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build \
|
# CGO is required (Makefile exports this too)
|
||||||
--progress=plain \
|
export CGO_ENABLED=1
|
||||||
--target lint \
|
golangci-lint run --timeout 5m
|
||||||
--no-cache-filter=lint \
|
|
||||||
--output=type=cacheonly \
|
|
||||||
-f Dockerfile.lint .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user