Compare commits

..
16 Commits
Author SHA1 Message Date
clawbot 2c6fe7c368 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #121)
check / check (push) Waiting to run
The vendored files are copies from sneak/prompts dd4027b, with this
repository's own entries after the canonical content. golangci-lint is
v2.14.0. Lint and test are phases of the Dockerfile, which script/lint
and script/test build with --no-cache; Dockerfile.lint and
script/lint-darwin are gone, and the lint phase also checks the macOS
build. The tests run on the Debian Go image with cgo and the race
detector. script/cibuild bootstraps, runs script/check and builds the
image; CHECK_EPOCH and the memlock ulimit are gone. Go's build cache
stays in a cache mount, out of the test image's layer. Agent guidance
lives in AGENTS.md alone; the tool-specific file is gone.

Model: opus-5-5
2026-10-07 05:42:59 +02:00
clawbot ed6af50ea5 Make script/test fail on flaky failures and enable -race (closes #32)
check / check (push) Waiting to run
script/test ended with a verbose rerun whose exit status became the
script's, so a test that failed once and passed on the retry gave a
green build. It now follows the REPO_POLICIES.md pattern: go vet, then
go test -count=1 -timeout 30s -race -cover; on failure a verbose rerun
for the details, then exit 1. -count=1 stays on both go test lines
because the Dockerfile keeps Go's build cache between builds.

The tests that gave the secret binary a minute, and the PGP unlocker
test's 30-second timer, now use 10 seconds, so a hang fails with the
test's own message before the package's 30-second timeout. The README
describes the new run.

Model: opus-5-5
2026-10-07 03:22:34 +02:00
clawbot b109c4e5e1 Keep Go's build cache between builds (closes #124)
check / check (push) Waiting to run
make test and make build in the Dockerfile now share one Go build cache,
kept in a BuildKit cache mount with this repository's own id, so they
compile only what changed. script/test passes -count=1.

Model: opus-5-5
2026-10-07 01:02:35 +02:00
clawbot ae030d759d Stop the terminal tests reading a pty nobody opened (closes #126)
check / check (push) Waiting to run
On Linux, pty.Open of github.com/creack/pty v1.1.24 passes the address
of a local variable to ioctl as a plain number, through a function
call. When Go moved the goroutine's stack in between, the kernel wrote
the pty's number to the old place, and pty.Open opened /dev/pts/0,
another terminal. secret rm wrote there, and the test read a terminal
no program had open, which never ends. Require the commit on the
library's main branch that passes a pointer; no release has it yet.

Both terminal tests now stop reading when their one-minute context
ends, and fail saying what was still waiting.

Model: opus-5-5
2026-10-07 00:02:40 +02:00
clawbot 4ff0d20c10 Make the tests fast under the race detector (closes #120)
check / check (push) Successful in 1m25s
Deriving keys from passphrases with scrypt, slow on purpose, took most
of the test time under -race. secret.ScryptWorkFactor, when not zero,
replaces age's work factor when a passphrase encrypts; the tests of
internal/secret, internal/vault and internal/cli set it to 1 in
TestMain, and the program never sets it. TestGetCommandOutputsToStdout
checks that the built binary's passphrase unlocker names age's 18.

TestRemovalAsksWithoutHoldingLock and TestFailedCommandReleasesLock
time the in-memory lock all tests share, so they no longer run in
parallel. TestConcurrentAddsKeepEveryVersion and
TestGetCommandOutputsToStdout time nothing and now do.

The script/cibuild comment no longer says tests are skipped without
its memlock ulimit.

Model: opus-5-5
2026-10-06 07:02:37 +02:00
clawbot df47ab386c Test only small secrets in the size tests (closes #52)
check / check (push) Successful in 2m38s
The size tests for secret add, secret import and the stdin buffer no
longer store 2 MB to 101 MB secrets; the largest is 1 MiB. The cases
checking that a secret over the 100 MB limit is rejected are deleted
and not replaced. The limit itself is unchanged. With nothing large
left, the helper that skipped a case for want of locked memory is gone.

Model: opus-5-5
2026-10-06 02:22:38 +02:00
clawbot af9ac6d979 Rename the Go module to sneak.berlin/go/secret (closes #43)
check / check (push) Failing after 2s
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>.
go.mod, every import (rewritten with gofmt -r), the -X flags in
script/build and the examples in the pkg READMEs now use the new path.
go mod tidy lists go-humanize and fatih/color as direct requirements,
since internal/cli imports them.

This breaks anyone who fetched or imported git.eeqj.de/sneak/secret:
they must switch to sneak.berlin/go/secret, which resolves to this
repository.

Model: opus-5-5
2026-10-05 02:45:37 +02:00
clawbot 047f347955 Format and check markdown with prettier in make fmt and fmt-check (closes #110)
check / check (push) Failing after 3s
script/fmt and script/fmt-check follow the model scripts in the prompts
repo: Go as before, plus prettier over every markdown file with 4-space
tabs and proseWrap always. Prettier is pinned by hash in package.json and
yarn.lock; script/bootstrap now installs node, yarn and prettier. The
Dockerfile lint stage copies node and yarn from a node image pinned by
hash and runs script/bootstrap, so its make fmt-check fails the build on
unformatted markdown. Every markdown file is formatted once; wording is
unchanged (CLAUDE.md's "*" list markers become "-").

Model: opus-5-5
2026-10-05 02:07:54 +02:00
clawbot 2503f2db96 Name only the mnemonic when it cannot be read (closes #115)
check / check (push) Failing after 2s
secret init and secret vault create read the mnemonic with the new
secret.ReadMnemonic, whose every error wraps the new
secret.ErrMnemonicNotRead. It shares the terminal read with ReadPassphrase,
whose errors still wrap ErrPassphraseNotRead. Without a terminal the error
names the environment variable that gives the value instead:
SB_SECRET_MNEMONIC for the mnemonic, SB_UNLOCK_PASSPHRASE for the
passphrase. A test pins the message of init without a terminal.

Model: opus-5-5
2026-10-05 01:43:00 +02:00
clawbot 43f66bf369 Give each failure one error value (closes #113)
check / check (push) Failing after 3s
internal/cli drops its copies of vault.ErrSecretNotFound, ErrVaultNotFound,
ErrVersionNotFound and ErrSecretExists and of the secret package's keychain
and Secure Enclave errors, and its second error for an unknown unlocker
type, an invalid mnemonic, a length below 1, an unsupported secret type and
an oversized secret. vault.ErrNilValueBuffer becomes
secret.ErrNilValueBuffer. Every error of secret.ReadPassphrase wraps
ErrPassphraseNotRead. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for
a key the keyring lacks. storeInKeychain returns errNilDataBuffer. bip85's
ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks,
as does the macOS check in macOS-only code. Tests that matched these
errors' text use errors.Is.

Model: opus-5-5
2026-10-05 01:08:01 +02:00
clawbot 176095e3d1 Check errors by identity, not by message text, in tests (closes #49)
check / check (push) Failing after 1s
Tests that asserted a failure by a fragment of its message now use
errors.Is: a refactor returning the wrong error, or wrapping with %v
instead of %w, now fails them. New tests return each exported error of
internal/vault and pkg/bip85 that no test returned, and check wrapped
causes (os.ErrNotExist, ErrMnemonicMismatch through GetSecret,
ErrInvalidPathComponent through DeriveBIP85Entropy). The 999-versions
test moves into package secret to name its unexported error. Checks of
errors no test can name keep their text; they are listed on the issue.

Model: opus-5-5
2026-10-04 23:25:06 +02:00
clawbot 2adc588ace Say the mnemonic still opens a vault its unlocker cannot (closes #47)
check / check (push) Failing after 2s
When a vault cannot be opened through its current unlocker, the error now
ends by naming the vault, saying that it still opens with its mnemonic,
and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set
gives it a new unlocker, after 'secret vault select' when it is not the
current vault. Only when the vault metadata records the key the mnemonic
derives, and not when the passphrase could not be read. 'secret encrypt'
and 'secret decrypt' read the key secret through vault.GetSecret, and
Secret.GetValue with its helpers is removed. An unreadable 'current'
file's error names 'secret version list' and 'secret version promote'.
Causes stay wrapped.

Model: opus-5-5
2026-10-04 21:59:02 +02:00
clawbot 0e6a4afb71 Make an unlocker's ID the name of its directory (closes #98)
check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the
minute plus the host name, and passphrase unlocker IDs the time to the
minute, so two created within one minute shared an ID, and `unlocker
select`, `unlocker remove` and the selection after `unlocker add` acted on
the older one. Every unlocker's ID is now its directory name, unique in
its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by
that name, so `unlocker list` and shell completion no longer find IDs by
matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second
PGP unlocker for one key is refused by comparing fingerprints in metadata.

Model: opus-5-5
2026-10-04 21:25:00 +02:00
clawbot 1a23fd3125 Make the README's storage and file format text match the code (closes #102)
check / check (push) Failing after 2s
The directory tree shows `current` and `currentvault` as plain files holding
a name, a version's metadata as the encrypted `metadata.age`, the real state
directory under the user's configuration directory, and the `lock` file.
`version promote` rewrites `current`. File Formats tells unencrypted vault
and unlocker metadata from encrypted version metadata; `pub.age` is plain
text and vault metadata holds no vault name. Unlocker bullets lose Touch ID
claims the code does not set up, and the Secure Enclave only decrypts.
Per-version keys no longer claim forward secrecy. Testing lists only
`make test`.

Model: opus-5-5
2026-10-04 20:58:42 +02:00
clawbot 23dcea83f9 Create a vault whole in a temporary directory, then select it (closes #105)
check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault
directory, its metadata, long-term public key and passphrase unlocker
into a temporary directory, renames that into vaults.d once complete,
and only then makes the vault current. secret init and secret vault
create call it once instead of adding the unlocker afterwards, so a
kill part-way leaves either no vault, whose temporary directory the
next command that takes the lock deletes, or a complete one. A test
records the state directory before every change the call makes and
checks each state, and the command run again from it.

Model: opus-5-5
2026-10-04 20:42:03 +02:00
clawbot f2f89c8a06 Delete the keychain item or Secure Enclave key of a failed unlocker add (closes #89)
check / check (push) Failing after 2s
A Secure Enclave unlocker add gets the long-term key before it creates
the Secure Enclave key, so a wrong passphrase creates none, and deletes
the key if a later step fails. macse.CreateKey finds the new key's hash
right after sc_auth creates it, failing with an error naming the label
if it cannot, and deletes the key if getting its public key then fails.
A keychain unlocker add writes all of the unlocker's files before it
stores the keychain item, and deletes the item if moving the unlocker
into place then fails. A failure to delete is reported along with the
original error.

The Objective-C and macse_darwin.go were only read, never compiled or
run; the new tests run only on a Mac.

Model: opus-5-5
2026-10-04 20:07:59 +02:00
112 changed files with 2916 additions and 2091 deletions
+68 -19
View File
@@ -1,29 +1,78 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the # .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which # stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there. # password in a remote URL or the token the CI checkout step stores there.
.git/config # Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Build artifacts # Agent scratch: one full checkout of the repo per in-flight agent.
secret # Anchored because it occurs once where agents run at the repo root.
coverage.out # KNOWN GAP: a repo running agents in subdirectories still ships
*.test # `services/api/.claude/` and must add its own anchored entry.
.claude
# IDE and editor files # Environment files. `*.env` covers bare `.env` and the `prod.env`
.vscode # convention. Re-include a committed template with a negation if the
.idea # build needs one: `!docs/example.env`.
*.swp **/*.[eE][nN][vV]
*.swo **/.[eE][nN][vV].*
*~ **/.[eE][nN][vV][rR][cC]
# Dependencies # Private keys and the bundles carrying them. Public certificates
node_modules # (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# macOS # Dependencies: restored inside the image, never copied in.
.DS_Store **/node_modules
# Claude files # OS metadata.
.claude/ **/.DS_Store
**/Thumbs.db
# Local settings # Editor state: never a build input, and it churns COPY.
.claude/settings.local.json **/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repo's host-built artifacts: the binary script/build writes, test
# binaries and coverage output.
/secret
/*.test
/coverage.out
+3
View File
@@ -10,3 +10,6 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
[*.go]
indent_style = tab
+1 -1
View File
@@ -4,6 +4,6 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# actions/checkout v4.2.2, 2026-02-28 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild - run: script/cibuild
+29 -9
View File
@@ -20,15 +20,35 @@ Thumbs.db
# Node # Node
node_modules/ node_modules/
# Environment / secrets # Secrets. Unanchored like every entry above, so each matches at every
.env # depth. Matching is case-sensitive on Linux, so names use character
.env.* # ranges rather than a lowercase form that misses `Server.Key`.
*.pem
*.key
# This repo. /secret is the built binary, anchored so that it does not # Environment files. `*.env` covers bare `.env` and the `prod.env`
# also match the internal/secret/ package directory. # convention. Only the templates `example.env` and `sample.env` are
/secret # re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# Go. /secret is the built binary, anchored so that it does not also match
# the internal/secret/ package directory.
*.log *.log
*.out
*.test *.test
settings.local.json /secret
+1
View File
@@ -17,6 +17,7 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- 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
+21 -2
View File
@@ -100,6 +100,18 @@ Version: 2025-06-08
are acceptable in the root, but source code and other files should be are acceptable in the root, but source code and other files should be
organized in appropriate subdirectories. organized in appropriate subdirectories.
13. Commit messages carry no author or co-author attribution for the coding
agent. The agent is an inanimate tool and the owner is the sole author of
the code it writes; such a line is advertising, not attribution.
14. Never run part of the test suite: always run the whole thing with
`make test`.
15. Do not stop working on a task until you have reached the definition of done
it gives. Do all of the work, not part or most of it.
16. After each commit, push to the remote.
## Python-Specific Guidelines ## Python-Specific Guidelines
1. **Type Annotations (UP006)**: Use built-in collection types directly for type 1. **Type Annotations (UP006)**: Use built-in collection types directly for type
@@ -154,10 +166,17 @@ Version: 2025-06-08
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability. 3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix 4. **Never modify linter config** (`.golangci.yml`) to suppress findings, and do
the code. not modify it at all unless specifically instructed. Fix the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no 5. **All PRs must pass `make check` with zero failures.** No exceptions, no
"pre-existing issue" excuses. "pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags. 6. **Pin external dependencies by commit hash**, not mutable tags.
7. **A program's `main.go` is `./cmd/<program_name>/main.go`** and only imports
and calls `<program_name>.CLIEntry()`; the implementation is in
`./internal/<program_name>/`. This keeps several programs in one repository
from cluttering the root directory.
8. **Log with `log/slog`.**
-95
View File
@@ -1,95 +0,0 @@
# IMPORTANT RULES
* Claude is an inanimate tool. The spam that Claude attempts to insert into
commit messages (which it erroneously refers to as "attribution") is not
attribution, as I am the sole author of code created using Claude. It is
corporate advertising for Anthropic and is therefore completely
unacceptable in commit messages.
* Tests should always be run before committing code. No commits should be
made that do not pass tests.
* Code should always be formatted before committing. Do not commit
unformatted code.
* Code should always be linted and linter errors fixed before committing.
NEVER commit code that does not pass the linter. DO NOT modify the linter
config unless specifically instructed.
* The test suite is fast and local. When running tests, NEVER run
individual parts of the test suite, always run the whole thing by running
"make test".
* Do not stop working on a task until you have reached the definition of
done provided to you in the initial instruction. Don't do part or most of
the work, do all of the work until the criteria for done are met.
* When you complete each task, if the tests are passing and the code is
formatted and there are no linter errors, always commit and push your
work. Use a good commit message and don't mention any author or co-author
attribution.
* Do not create additional files in the root directory of the project
without asking permission first. Configuration files, documentation, and
build files are acceptable in the root, but source code and other files
should be organized in appropriate subdirectories.
* Do not use bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top of
the file) and give it a descriptive name, then use that constant in the
code instead of the bare string or number.
* If you are fixing a bug, write a test first that reproduces the bug and
fails, and then fix the bug in the code, using the test to verify that the
fix worked.
* When implementing new features, be aware of potential side-effects (such
as state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests when designing an
API.
* When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs
to see the time in a different timezone, store the user's timezone in a
separate field and convert the UTC time to the user's timezone when
displaying it. For internal use and internal applications and
administrative purposes, always display UTC.
* When implementing programs, put the main.go in
./cmd/<program_name>/main.go and put the program's code in
./internal/<program_name>/. This allows for multiple programs to be
implemented in the same repository without cluttering the root directory.
main.go should simply import and call <program_name>.CLIEntry(). The
full implementation should be in ./internal/<program_name>/.
* When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there
is a bug in the test). This is cheating, and it is bad. You should only
be modifying the test if it is incorrect or if the test is no longer
relevant. In almost all cases, you should be fixing the code that is
being tested, or updating the tests to match a refactored implementation.
* Always write a `Makefile` with the default target being `test`, and with a
`fmt` target that formats the code. The `test` target should run all
tests in the project, and the `fmt` target should format the code. `test`
should also have a prerequisite target `lint` that should run any linters
that are configured for the project.
* After each completed bugfix or feature, the code must be committed. Do
all of the pre-commit checks (test, lint, fmt) before committing, of
course. After each commit, push to the remote.
* Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new
feature, write a test for it. You don't need to target complete coverage,
but you should at least test any new functionality you add.
* Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output
the structured logs in jsonl format. Use go's log/slog.
* You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out of
the ordinary happened, please explain it, but in the normal case where you
found and fixed the bug, or implemented the feature, there is no need for
the end-of-change summary.
+54 -43
View File
@@ -1,68 +1,79 @@
# Lint stage — fast feedback on formatting and lint issues # Lint phase. The linter is invoked directly rather than through `make
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # lint` or `script/lint`, which are themselves a docker build.
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
# below run again on each build, an unchanged tree included, while the
# steps above stay cached. ARG is per stage: the build stage declares it too.
ARG CHECK_EPOCH
COPY . . COPY . .
RUN go vet ./...
RUN make fmt-check
# Not make lint or make lint-darwin: script/lint and script/lint-darwin are
# docker builds, which cannot run in here. These are their commands.
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# The same checks on the code as a macOS build compiles it, which a Linux
# build never compiles. Cgo is off, because compiling cgo code for macOS
# needs Apple's SDK headers. That leaves out the files built only with cgo
# on macOS: the keychain unlocker's calls into the keychain
# (keychainunlocker_cgo.go, and keychainunlocker_test.go) and the Secure
# Enclave bindings (internal/macse). Nothing on Linux checks those.
RUN GOOS=darwin CGO_ENABLED=0 go vet ./... RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./... RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang 1.24.13-alpine (2026-03-10) # image ships and the alpine one does not.
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder # golang:1.24.13-trixie, 2026-02-04
FROM golang@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS test
# Force BuildKit to run the lint stage ENV CGO_ENABLED=1
COPY --from=lint /src/go.sum /dev/null WORKDIR /src
RUN apk add --no-cache gcc musl-dev make git gnupg
WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . .
# Go's build cache goes in a cache mount, not the image layer, which would
# take seconds longer to export. --no-cache, on every build in script/,
# starts the mount empty; -count=1 keeps a build without it from taking
# test results from there.
RUN --mount=type=cache,id=sneak/secret/go-build-test,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# As in the lint stage: the RUN steps below run again on each script/cibuild. # Build stage. Nothing is wanted from either phase above; the copies are
ARG CHECK_EPOCH # what make BuildKit build them first, so this stage cannot run unless
# lint and test passed.
# golang 1.24.13-alpine, 2026-03-10
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# script/build compiles with cgo, so it needs a C compiler too.
RUN apk add --no-cache gcc musl-dev make git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
RUN make test # The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# The version stamped into the binary: the VERSION build argument when one # .git present, a version that is still empty, dev or unknown fails the
# is given, otherwise `git describe --tags --always` of the .git the build # build: git is missing or could not read the checkout.
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
# one, the short commit when no tag is reachable. A context that carries .git
# and still yields no version fails the build.
ARG VERSION ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ if [ -e .git ]; then \
[ "$version" = unknown ]; }; then \ case "$VERSION" in ""|dev|unknown) \
echo "no version could be derived although the build context carries .git" >&2; \ echo "version is '$VERSION' although .git is present" >&2; \
exit 1; \ exit 1 ;; \
esac; \
fi; \ fi; \
make build VERSION="${version:-dev}" make build VERSION="${VERSION:-dev}"
# Runtime stage # Runtime stage, and the last one
# alpine 3.23 (2026-03-10) # alpine 3.23, 2026-03-10
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates gnupg RUN apk add --no-cache ca-certificates gnupg
RUN adduser -D -s /bin/sh secret RUN adduser -D -s /bin/sh secret
COPY --from=builder /build/secret /usr/local/bin/secret COPY --from=builder /src/secret /usr/local/bin/secret
RUN chmod +x /usr/local/bin/secret RUN chmod +x /usr/local/bin/secret
USER secret USER secret
-29
View File
@@ -1,29 +0,0 @@
# Lint image, built by script/lint and script/lint-darwin: golangci-lint runs
# as a build step, so a successful build is a clean lint. Works where the
# docker daemon is remote and bind mounts are impossible.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# script/lint rebuilds this stage on every run, by this name; the module
# download above stays cached.
FROM deps AS lint
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# script/lint-darwin rebuilds this stage on every run, by this name. It
# checks the code as a macOS build compiles it, but with cgo off, which
# leaves out the files that need cgo on macOS (see script/lint-darwin).
FROM deps AS lint-darwin
COPY . .
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
+3 -6
View File
@@ -1,7 +1,7 @@
export CGO_ENABLED=1 export CGO_ENABLED=1
.PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \ .PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
check docker docker-run clean install hooks docker-run clean install hooks
default: check default: check
@@ -21,13 +21,10 @@ test:
fmt: fmt:
@script/fmt @script/fmt
# Vets and lints the Linux build, then the macOS build
lint: lint:
@script/lint @script/lint
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
lint-darwin:
@script/lint-darwin
check: check:
@script/check @script/check
+55 -38
View File
@@ -180,8 +180,8 @@ period.
#### `secret version promote <secret-name> <version>` #### `secret version promote <secret-name> <version>`
Promotes a specific version to current by updating the symlink. Does not modify Promotes a specific version to current by rewriting the secret's `current` file
any timestamps, allowing for rollback scenarios. to name it. Does not modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑 #### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
@@ -211,7 +211,9 @@ Generates and stores a random secret.
#### `secret unlocker list [--json]` / `secret unlocker ls` #### `secret unlocker list [--json]` / `secret unlocker ls`
Lists all unlockers in the current vault with their metadata. Lists all unlockers in the current vault with their metadata. An unlocker's ID,
which `secret unlocker select` and `secret unlocker remove` take, is the name of
its directory in `unlockers.d`.
#### `secret unlocker add <type> [options]` #### `secret unlocker add <type> [options]`
@@ -278,8 +280,13 @@ Decrypts data using an Age key stored as a secret.
### Directory Structure ### Directory Structure
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
the state directory instead. On Linux:
``` ```
~/.local/share/secret/ ~/.config/berlin.sneak.pkg.secret/
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
@@ -292,12 +299,12 @@ Decrypts data using an Age key stored as a secret.
│ │ │ │ │ │ ├── pub.age # Version public key │ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted) │ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value │ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata │ │ │ │ │ │ └── metadata.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version │ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001 │ │ │ │ └── current # Current version's name: 20231216.001
│ │ │ └── database%password/ # Secret: database/password │ │ │ └── database%password/ # Secret: database/password
│ │ │ ├── versions/ │ │ │ ├── versions/
│ │ │ └── current -> versions/20231215.001 │ │ │ └── current # Current version's name: 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 # Current unlocker's directory name
@@ -307,9 +314,13 @@ Decrypts data using an Age key stored as a secret.
│ ├── vault-metadata.json │ ├── vault-metadata.json
│ ├── pub.age │ ├── pub.age
│ └── current-unlocker │ └── current-unlocker
└── currentvault -> vaults.d/default ├── currentvault # Current vault's name: default
└── lock # Locked by each command that changes anything
``` ```
`current`, `currentvault` and `current-unlocker` are plain files that each hold
one name. Changing one replaces it in one rename, so it is never half-written.
### Key Management and Encryption Flow ### Key Management and Encryption Flow
#### 1: Long-term Keys #### 1: Long-term Keys
@@ -336,7 +347,7 @@ Unlockers provide different authentication methods to access the long-term keys:
3. **Keychain Unlockers** (macOS only): 3. **Keychain Unlockers** (macOS only):
- Stores unlock keys in macOS Keychain - Stores unlock keys in macOS Keychain
- Protected by system authentication (Touch ID, password) - Kept on this Mac only: the keychain item is never synced to other devices
- Automatic unlocking when Keychain is unlocked - Automatic unlocking when Keychain is unlocked
- Cross-application integration - Cross-application integration
@@ -344,8 +355,10 @@ Unlockers provide different authentication methods to access the long-term keys:
- Hardware-backed key storage using Apple Secure Enclave - Hardware-backed key storage using Apple Secure Enclave
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
Program required) Program required)
- ECIES encryption: vault long-term key encrypted directly by SE hardware - ECIES encryption: the vault long-term key is encrypted directly to the SE
- Protected by biometric authentication (Touch ID) or system password key, and only the SE can decrypt it
- The SE key cannot leave this Mac; using it asks for no Touch ID or
password
Each vault maintains its own set of unlockers and one long-term key. The Each vault maintains its own set of unlockers and one long-term key. The
long-term key is encrypted to each unlocker, allowing any authorized unlocker to long-term key is encrypted to each unlocker, allowing any authorized unlocker to
@@ -355,7 +368,7 @@ access vault secrets.
- Each secret version has its own encryption key pair - Each secret version has its own encryption key pair
- Private key encrypted to the vault's long-term key - Private key encrypted to the vault's long-term key
- Provides forward secrecy and granular access control - A version's private key decrypts only that version's value and metadata
### Environment Variables ### Environment Variables
@@ -505,17 +518,21 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### File Formats ### File Formats
- **age Files**: Standard age encryption format (.age extension) - **age Files**: Standard age encryption format (.age extension), except
- **Metadata**: Unencrypted JSON format with timestamps and type information `pub.age`, which holds an age public key as text
- **Vault Metadata**: JSON containing vault name, creation time, derivation - **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
index, and public key hash unencrypted JSON with a creation time, and `unlocker-metadata.json` also
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
the version's public key
- **Vault Metadata**: JSON containing creation time, derivation index, and the
public key hashes described below
### Vault Management ### Vault Management
- **Derivation Index**: Each vault uses a unique derivation index from the - **Derivation Index**: Each vault uses a unique derivation index from the
mnemonic, and thus a unique key pair mnemonic, and thus a unique key pair
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies - **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
vaults from the same mnemonic hash of the index-0 public key identifies vaults from the same mnemonic
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are - **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
automatically derived automatically derived
@@ -566,8 +583,6 @@ The project includes comprehensive tests:
```bash ```bash
make test # Run all tests make test # Run all tests
go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests
``` ```
## Entrypoints ## Entrypoints
@@ -578,8 +593,10 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We development workflow, and the Makefile targets are thin shims that call them. We
provide: provide:
- `script/bootstrap` — install all dependencies (Go, Go module download), - `script/bootstrap` — install all dependencies (Go, Go module download, and
idempotently; golangci-lint is not installed, it runs in docker node, yarn and prettier for formatting markdown), idempotently; prettier is
pinned by hash in `package.json` and `yarn.lock`; golangci-lint is not
installed, it runs in 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 other - `script/projectname` — output the project name (`secret`); used by other
@@ -587,24 +604,24 @@ provide:
- `script/build` — build the `secret` binary into the repo root, stamping the - `script/build` — build the `secret` binary into the repo root, stamping the
version (`VERSION` from the environment, else `git describe`) and the git version (`VERSION` from the environment, else `git describe`) and the git
commit commit
- `script/test` — run `go vet` and the test suite (verbose rerun on failure) - `script/test` — build the `test` phase of the `Dockerfile`, which runs the
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`, test suite with the race detector, a 90-second timeout and coverage; on
where the linter is a build step that runs on every call, also on an unchanged failure it reruns the tests verbosely for the details and fails even when the
tree rerun passes
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code - `script/lint` — build the `lint` phase of the `Dockerfile`, which runs
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never `go vet` and `golangci-lint`, then both again on the code as a macOS build
compiles; cgo is off, so the keychain unlocker's calls into the keychain compiles it (`GOOS=darwin`), which a Linux build never compiles; cgo is off
there, so the keychain unlocker's calls into the keychain
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`) (`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
and the Secure Enclave bindings (`internal/macse`) are not checked and the Secure Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes) - `script/fmt` — format all Go code with `go fmt` and every markdown file with
- `script/fmt-check` — check formatting without writing prettier (4-space tabs, `proseWrap: always`) (writes)
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and - `script/fmt-check` — check the same formatting without writing, on the host
`script/fmt-check` - `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name; the
- `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .` build runs the `lint` and `test` phases first
(memguard needs mlock; the Dockerfile runs the checks), with a new - `script/cibuild` — CI entrypoint: runs `script/bootstrap`, `script/check`,
`CHECK_EPOCH` build argument on every run so the checks run again on an then builds the image as `script/docker` does
unchanged tree
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then - `script/precommit` — pre-commit checks: `go mod tidy` verification, then
`script/check` `script/check`
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
+355 -84
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-07-06 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,17 +60,28 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
scripts are our own extensions to the standard: `script/check` runs with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is `script/bootstrap` first**, because the workflow checks out the repo and runs
what the git pre-commit hook runs, and it calls `script/check`; nothing else, while `script/fmt-check` runs the formatter on the host: on a
`script/install-precommit` installs the git pre-commit hook (the `make hooks` pristine checkout with nothing installed the run dies there, after the
target shims to it); and `script/projectname` (literally that filename) simply containerised gates have passed. **The bootstrap alone is not enough**:
outputs the project's name. Scripts that need the name call `script/bootstrap` installs node and yarn under nvm and leaves neither on the
`script/projectname` — e.g. `script/docker` assembles its image tag from it — `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
so those scripts stay byte-identical across all repos. Repo-type-specific entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in source nvm for the pinned node version before invoking it, exactly as
`script/precommit`, not in the hook itself. Model scripts are at `script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. Four further scripts are our
own extensions to the standard: `script/check` runs `script/test`,
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -89,87 +100,198 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`, and it carries the repo's gates: a
as a build step so the build fails if the branch is not green. For non-server `lint` phase and a `test` phase, with the final stage depending on both so the
repos, the Dockerfile should bring up a development environment and run image cannot be built unless they pass. For non-server repos the final stage
`make check`. For server repos, `make check` should run as an early build brings up a development environment; for server repos it is the runtime image.
stage before the final image is assembled. Dockerfiles install development The gate phases and the build stage start from their pinned base images and
prerequisites by running `script/bootstrap` rather than duplicating installs install what those images lack either inline, as the canonical Go `Dockerfile`
inline; COPY `script/` and the dependency manifests (`package.json` + below does for `git`, or by running `script/bootstrap`, as the `prompts`
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap repo's own `Dockerfile` does for its yarn packages. The development
layer stays cached until dependencies change. environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
repos use a multistage build where linting runs in an independent stage based no separate lint file. `script/lint` and `script/test` each build one phase
on the `golangci/golangci-lint` image (pinned by hash). This stage runs and nothing else:
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is: ```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
```dockerfile ```dockerfile
# Lint stage — fast feedback on formatting and lint issues # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make fmt-check RUN golangci-lint run --config .golangci.yml ./...
RUN make lint
# Build stage # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
FROM golang@sha256:... AS builder # golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
ARG VERSION=dev # Build stage. Nothing is wanted from either phase above; the copies
RUN CGO_ENABLED=0 go build -trimpath \ # are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/ -o /app ./cmd/app/
# Runtime stage # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it - The lint phase uses the `golangci/golangci-lint` image directly (it has
includes both Go and the linter), so there is no need to install the both Go and the linter), so nothing needs installing.
linter separately. - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates purpose is the ordering edge. BuildKit runs stages in parallel by default,
a stage dependency. BuildKit runs stages in parallel by default; without and a stage nothing depends on is not built at all, so without these two
this line, the build stage would not wait for lint to finish and a lint lines a red gate would not fail the build.
failure might not fail the overall build. - Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to - If the project requires CGO or system libraries for linting, install them
fail fast. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- If the project requires CGO or system libraries for linting (e.g. has no `apk`, so install with `apt-get` under the Debian package name
`vips-dev`), install them in the lint stage with `apk add`. (`libvips-dev`, where alpine says `vips-dev`), and delete the package
- The build stage runs `make test` after compilation setup. Tests run in the lists in the same `RUN`, so the layer does not keep them:
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies. ```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the runs `script/cibuild` on push, and checks out the repo as its only other step.
Dockerfile already runs `make check`, a successful build implies all checks That script bootstraps, runs the gate phases, and then builds the image, so a
pass. successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -189,14 +311,21 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op. `make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 60 seconds. That is the hard cap, and a
Makefile. suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
one.
- **`make test` should use the conditional verbose rerun pattern.** Run tests - **The test command should use the conditional verbose rerun pattern.** Run
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to tests without `-v` (verbose) first. If tests fail, automatically rerun with
show full output. This keeps CI logs and `docker build` output clean on `-v` to show full output. This keeps CI logs and `docker build` output clean
success (just package/suite summaries) while providing full diagnostic detail on success (just package/suite summaries) while providing full diagnostic
on failure (every test case, every assertion). The general shell pattern: detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile ```makefile
test: test:
@@ -209,11 +338,26 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
```makefile ```makefile
@@ -239,10 +383,84 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
Fetch the standard `.gitignore` from language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
a new repo. setting up a new repo. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -258,9 +476,56 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized. The vendored copy in a consuming repo must
manually by the user. Fetch from _NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
byte-identical, so that no repo can quietly loosen its own linting. Linter
configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -374,12 +639,14 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples - `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform) - `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root) - `docs/` — documentation and markdown (README.md stays in root)
@@ -406,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+474 -304
View File
@@ -18,354 +18,524 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 2026-10-07: The canonical files are re-vendored from `sneak/prompts` commit
`dd4027b` (https://git.eeqj.de/sneak/secret/issues/121), with golangci-lint
v2.14.0 in the lint phase. Lint and test are phases of the `Dockerfile`, and
`script/lint` and `script/test` each build one with `--no-cache`;
`Dockerfile.lint` and `script/lint-darwin` are gone, and the lint phase runs
`go vet` and checks the macOS build too. The tests run on the Debian Go image
with cgo and the race detector, with the policy's 90-second timeout.
`script/cibuild` bootstraps, runs `script/check` and builds the image;
`CHECK_EPOCH` and the memlock ulimit are gone. `--no-cache` starts Go's build
cache mount empty, so `make test` compiles everything on every run. The rules
in `CLAUDE.md` that `AGENTS.md` lacked are in `AGENTS.md`, and `CLAUDE.md` is
deleted.
- 2026-10-06: `script/test` runs the tests with the race detector, a 30-second
timeout per package and coverage, as `REPO_POLICIES.md` requires
(https://git.eeqj.de/sneak/secret/issues/32). When they fail, it reruns them
verbosely for the details and then fails anyway, so a test that fails once and
passes on the retry no longer gives a green build. `go vet` still runs first,
and every `go test` keeps `-count=1`. The tests that gave the `secret` binary
a minute now give it 10 seconds, and the PGP unlocker test's 30-second timer
is 10 seconds, so a test that hangs fails with its own message before the
package's 30-second timeout ends every test in it.
- 2026-10-06: `make test` in `script/cibuild` no longer compiles the standard
library and every dependency from nothing on every build
(https://git.eeqj.de/sneak/secret/issues/124). The `Dockerfile` runs it and
`make build` with Go's build cache in a BuildKit cache mount, which docker
keeps between builds, so each compiles only what changed since the last build.
The mount has an id of its own, so other repositories' builds do not share it.
`script/test` passes `-count=1`, so every test runs on every build and no
result comes from Go's test cache. A build with an empty cache, such as the
first after docker's build cache is cleared, compiles everything in
`make test` as before.
- 2026-10-06: `TestRemoveIgnoresTerminalOnStdout` and
`TestRemoveAsksAtTerminalOnStdin` no longer wait until Go's test timeout
(https://git.eeqj.de/sneak/secret/issues/126). On Linux, `pty.Open` of
`github.com/creack/pty` v1.1.24 passed the address of a local variable to the
`ioctl` system call as a plain number, through a function call; when Go moved
the goroutine's stack in between, the kernel wrote the terminal's number to
the old place, and `pty.Open` opened `/dev/pts/0` instead of the terminal it
had created. `secret rm` then wrote to that other terminal, and the test read
a terminal no program had open, which never ends. `go.mod` now requires the
commit on that library's main branch that passes a pointer instead; no release
has it yet. Both tests stop reading the terminal when their one-minute context
ends and fail saying so.
- 2026-10-06: The tests run quickly with the race detector on
(https://git.eeqj.de/sneak/secret/issues/120). Most of their time went to
deriving keys from passphrases with scrypt, which is slow on purpose. The new
`secret.ScryptWorkFactor`, when not zero, replaces age's scrypt work factor
when a passphrase encrypts; the tests of `internal/secret`, `internal/vault`
and `internal/cli` set it to 1 before any test runs, and the program never
sets it; `TestGetCommandOutputsToStdout` checks that the passphrase unlocker
the built binary's `secret init` writes names age's work factor, 18.
`TestRemovalAsksWithoutHoldingLock` and `TestFailedCommandReleasesLock` no
longer run in parallel with other tests: each waits at most 10 seconds for the
in-memory lock that every test in the package shares, and other tests'
commands held it longer. `TestConcurrentAddsKeepEveryVersion`, which times
nothing, and `TestGetCommandOutputsToStdout`, which no longer sets an
environment variable its commands do not read, now run in parallel. The
`script/cibuild` comment no longer says that tests are skipped without its
memlock ulimit.
- 2026-10-05: No test stores a secret larger than 1 MiB
(https://git.eeqj.de/sneak/secret/issues/52). The size tests for `secret add`,
`secret import` and the stdin buffer no longer try 2 MB, 10 MB, 99 MB, 100 MB
or 101 MB secrets, and nothing tests that a secret over the 100 MB limit is
rejected; the limit itself is unchanged. With nothing large left, the size
tests no longer skip a case for want of locked memory.
- 2026-10-05: The Go module path is `sneak.berlin/go/secret`, as
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret`
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the
`-X` flags in `script/build` that stamp the version and commit shown by
`secret info`, and the examples in `pkg/agehd/README.md` and
`pkg/bip85/README.md`. `go mod tidy` now lists `github.com/dustin/go-humanize`
and `github.com/fatih/color`, which `internal/cli` imports, as direct
requirements. Code that imported the old path must switch to the new one.
- 2026-10-04: `make fmt` formats every markdown file with prettier (4-space
tabs, `proseWrap: always`) as well as the Go code, and `make fmt-check` checks
both, as the model scripts in the `prompts` repo do
(https://git.eeqj.de/sneak/secret/issues/110). Prettier is pinned by hash in
`package.json` and `yarn.lock`, and `script/bootstrap` installs node, yarn and
prettier. The `Dockerfile` lint stage copies node and yarn from a node image
pinned by hash and runs `script/bootstrap`, so its `make fmt-check` fails the
build on an unformatted markdown file. Every markdown file was formatted once,
wording unchanged.
- 2026-10-04: A mnemonic that cannot be read, in `secret init` and
`secret vault create`, gives an error that names the mnemonic only
(https://git.eeqj.de/sneak/secret/issues/115). It is read with
`secret.ReadMnemonic`, whose every error wraps the new
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
message said "failed to read mnemonic: failed to read passphrase:" and advised
setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
mnemonic: stdin is not a terminal (piped input or script). Please set the
SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
messages no longer repeat "cannot read passphrase" after "failed to read
passphrase:", and empty input gives "nothing was entered".
- 2026-10-04: A failure returns the same error value whichever command hits it
(https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
`secret` package's error for it, not an `internal/cli` copy; on macOS, the
check that the system is macOS is gone, as it could never fail.
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
mnemonic, as `init` and `vault create` do. `secret generate secret` gives
`errLengthTooSmall` for a length below 1 wherever it is checked, and
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
`secret` already returned under another name. Messages are unchanged, except
that `secret decrypt` of a missing secret says "not found", as `secret get`
does, not "does not exist"; `vault import` of an invalid mnemonic says
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
import says
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
which supplies the words "failed to read passphrase" that its callers used to
add themselves; so two passphrases that differ now give only "passphrases do
not match", the words now follow "failed to read mnemonic:" and "failed to
read passphrase confirmation:", and a terminal read error no longer repeats
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
found by gpg's status line for "No public key"; before, the message repeated
"failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
always give 86 Base64 or 80 Base85 characters, the most a password length may
ask for. Tests that matched these errors' text use `errors.Is`.
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
Every exported error that can be returned has a test that the function returns
it, and errors wrapping a cause are checked through the wrapping. Checks that
still match text, because the error has no exported value the test can name,
are listed on the issue.
- 2026-10-04: When a vault cannot be opened through its current unlocker,
because a file the unlocker needs is missing or damaged, its keychain item or
Secure Enclave key is gone, or the passphrase is wrong, the error now ends by
naming the vault, saying that it still opens with its mnemonic, and that
`secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it,
gives the vault a new unlocker; for a vault that is not the current one, as in
`secret move` between vaults, it says to run `secret vault select` first
(https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare
cause. The advice is given only when the vault metadata records the key the
mnemonic derives, so not for a vault created without a mnemonic, and not when
the passphrase could not be read at all. `secret vault import` is not named:
it refuses a vault that has a long-term key. `secret encrypt` and
`secret decrypt` now read the key secret through `vault.GetSecret`, as
`secret get` does, so they give the same advice; `Secret.GetValue`, the other
way to get the long-term key, is removed. When a secret's `current` file
cannot be read, the error says that `secret version list` lists its versions
and `secret version promote` makes one current. The causes stay wrapped.
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so
no two unlockers of a vault share one
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
Enclave unlocker's ID was its creation time to the minute and the host name,
and a passphrase unlocker's the time to the minute, so two created within a
minute shared an ID, and `unlocker select`, `unlocker remove` and the
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID was
`pgp-` and its key's fingerprint; a second PGP unlocker for a key is still
refused, now by comparing the fingerprint in the other unlockers' metadata.
`unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read from,
no longer by matching metadata, so two unlockers with the same metadata are
listed apart; an unlocker of an unknown type is listed under its directory
name, and completion now offers Secure Enclave unlockers too. The keychain and
Secure Enclave code was type-checked by `script/lint-darwin`, never run; a
test on Linux lists, completes, selects and removes each of two passphrase
unlockers with the same metadata by its own ID.
- 2026-10-04: README's Storage Architecture, `secret version promote`, Technical
Details and Testing text matches the code
(https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault`
are plain files holding a name, not symbolic links; a version's metadata is
the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret`
in the user's configuration directory, not `~/.local/share/secret`, and holds
the `lock` file. Also corrected: the code sets up no Touch ID for the keychain
or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version
keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata
holds no vault name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or - 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105). not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
`vault.CreateVault` now takes the unlocker passphrase too, writes the vault now takes the unlocker passphrase too, writes the vault directory with its
directory with its metadata, long-term public key and passphrase unlocker, metadata, long-term public key and passphrase unlocker, `longterm.age`
`longterm.age` included, into a temporary directory, renames that into included, into a temporary directory, renames that into `vaults.d` once it is
`vaults.d` once it is complete, and only then makes the vault current. complete, and only then makes the vault current. Before, either command killed
Before, either command killed after the passphrase prompt but before the after the passphrase prompt but before the unlocker was written left a vault
unlocker was written left a vault with no unlocker, which `vault create` had with no unlocker, which `vault create` had already made current and which
already made current and which neither command would create again. Killed neither command would create again. Killed part-way now, it leaves no vault,
part-way now, it leaves no vault, and the next command that takes the lock and the next command that takes the lock deletes the temporary directory; or,
deletes the temporary directory; or, killed between the rename and making killed between the rename and making the vault current, a complete vault that
the vault current, a complete vault that is not current, which is not current, which `secret vault select` makes current.
`secret vault select` makes current. - 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item or
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
key again if encrypting with it or writing the unlocker then fails.
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates it,
and fails with an error naming the key's label if it cannot; it deletes the
key again if getting its public key then fails. The Objective-C was only read,
never compiled or run, and so was `macse_darwin.go`, which is cgo only.
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
among them, before it stores the item in the keychain, and deletes the item
again if moving the unlocker into place then fails. A failure to delete is
reported along with the first error. The tests of this run only on macOS: the
Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the
keychain one in a build with cgo.
- 2026-10-04: What a command killed part-way left under a `.tmp-` name - 2026-10-04: What a command killed part-way left under a `.tmp-` name
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories (https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
of `secret.TempDirFor` and the temporary files of `secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
`secret.WriteFileAtomic`, encrypted keys included, is deleted by the next encrypted keys included, is deleted by the next command that takes the state
command that takes the state directory lock. Before, it stayed until directory lock. Before, it stayed until deleted by hand. A command writes
deleted by hand. A command writes `finished` into the lock file just `finished` into the lock file just before it releases the lock; the next one
before it releases the lock; the next one to take the lock searches only to take the lock searches only when it does not find that, so after a command
when it does not find that, so after a command that finished nothing is that finished nothing is searched, however many secrets and versions there
searched, however many secrets and versions there are. The search looks are. The search looks in the state directory, each vault, each secret and each
in the state directory, each vault, each secret and each version, the version, the only directories those helpers make them in. A command that only
only directories those helpers make them in. A command that only reads reads takes no lock and deletes nothing. A failure to delete is warned about
takes no lock and deletes nothing. A failure to delete is warned about
and the command goes on. An unlocker directory with no metadata file was and the command goes on. An unlocker directory with no metadata file was
already removed by `secret unlocker remove` given its directory name; a already removed by `secret unlocker remove` given its directory name; a test
test now shows it. now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer - 2026-10-04: An age identity's private key goes into a locked buffer through
through `secret.IdentityToLockedBuffer` everywhere `secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key (https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
when a passphrase, PGP, keychain or Secure Enclave unlocker is created, passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
the new unlocker's own key, a new secret version's key, and the key unlocker's own key, a new secret version's key, and the key `secret encrypt`
`secret encrypt` generates. Before, each place converted the string age generates. Before, each place converted the string age returns to bytes and
returns to bytes and left the string in ordinary memory. The function left the string in ordinary memory. The function moves the string's own bytes
moves the string's own bytes into the buffer, which overwrites them; the into the buffer, which overwrites them; the copies age makes while writing the
copies age makes while writing the string remain, as its comment says. string remain, as its comment says. The 1.0 memory-security entry below no
The 1.0 memory-security entry below no longer lists these places, longer lists these places, `internal/cli/crypto.go` among them, nor
`internal/cli/crypto.go` among them, nor `version.go:155`, which was `version.go:155`, which was `internal/secret/version.go`, not
`internal/secret/version.go`, not `internal/cli/version.go`. `internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and - 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50).
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and `script/check` runs it, and the `Dockerfile` lint stage runs its commands, so
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too. `script/cibuild` does too. Before, CI on Linux never compiled the files built
Before, CI on Linux never compiled the files built only for macOS. Compiling only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS.
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions So the three functions that call `go-keychain` moved from
that call `go-keychain` moved from `keychainunlocker.go` to `keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on
`keychainunlocker_cgo.go`, built only with cgo on macOS like macOS like `macse_darwin.go`. A macOS build without cgo, which before did not
`macse_darwin.go`. A macOS build without cgo, which before did not compile, compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors errors say the keychain or Secure Enclave needs a macOS build with cgo. The
say the keychain or Secure Enclave needs a macOS build with cgo. The check check covers the rest of the keychain unlocker, the Secure Enclave unlocker
covers the rest of the keychain unlocker, the Secure Enclave unlocker and and the macOS-only tests other than `keychainunlocker_test.go`, whose lint
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved `GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into
into functions of their own, and the Secure Enclave unlocker derives the functions of their own, and the Secure Enclave unlocker derives the long-term
long-term key from the mnemonic through the same function as the keychain key from the mnemonic through the same function as the keychain unlocker
unlocker instead of a copy of it. Lines over 88 columns in the files the instead of a copy of it. Lines over 88 columns in the files the check cannot
check cannot see are wrapped. see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and - 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything `secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the (https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then last, and for the last the vault's secret count and that the vault then opens
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
`--force`, a command whose stdin is not a terminal fails at once. `--force` command whose stdin is not a terminal fails at once. `--force` (now also on
(now also on `rm` and `version rm`) removes without asking; it replaces the `rm` and `version rm`) removes without asking; it replaces the old refusals to
old refusals to remove a vault with secrets or the last unlocker of one remove a vault with secrets or the last unlocker of one without `--force`,
without `--force`, which the question now covers. The checks run, and the which the question now covers. The checks run, and the question is asked,
question is asked, before the state directory lock is taken; under the before the state directory lock is taken; under the lock the checks run again,
lock the checks run again, and if they would ask a different question, and if they would ask a different question, nothing is removed. `secret rm`
nothing is removed. `secret rm` fails when it cannot count the versions. fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a - 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a (https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond: directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure `passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
Enclave unlocker the keychain item or Secure Enclave key, which names the unlocker the keychain item or Secure Enclave key, which names the directory,
directory, carries the time instead of the day. `secret.WriteDir` fails on a carries the time instead of the day. `secret.WriteDir` fails on a directory
directory that exists instead of writing into it. `unlocker add passphrase` that exists instead of writing into it. `unlocker add passphrase` writes the
writes the new unlocker, makes it current, and only then removes the vault's new unlocker, makes it current, and only then removes the vault's other
other passphrase unlockers; a crash between the last two steps leaves the old passphrase unlockers; a crash between the last two steps leaves the old one
one beside the new, and the old passphrase still opens the vault through it beside the new, and the old passphrase still opens the vault through it until
until the next `unlocker add passphrase` or an `unlocker remove` removes it. the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
A PGP, keychain or Secure Enclave unlocker added on the same host and day as keychain or Secure Enclave unlocker added on the same host and day as another
another of its type is added beside it instead of replacing it. of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
unset at once, so that no program the command runs, `gpg` included, at once, so that no program the command runs, `gpg` included, inherits them
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below (https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
the command reads the environment; the buffers are passed down: the environment; the buffers are passed down: `vault.CreateVault` takes the
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its mnemonic (nil for none), a `Vault` derives its long-term key from its
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a `Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker PGP, keychain and Secure Enclave unlocker constructors take both.
constructors take both. `CreatePGPUnlocker` sets both on the vault it `CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of `SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and `GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
`vault create` no longer put the mnemonic into the environment. Unsetting into the environment. Unsetting erases nothing: the starting environment
erases nothing: the starting environment (`/proc/<pid>/environ`) and (`/proc/<pid>/environ`) and memory still hold the value. The README warns
memory still hold the value. The README warns against both variables. against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from - 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
`sneak/prompts`, byte for byte byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2` `gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
in place of the deprecated `gomodguard`, so the lint no longer warns, warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
and enables `depguard` with a rule that keeps `net/http/httptest` out of of non-test files. Neither raised a finding in this repo.
non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux - 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets (https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
the vault's long-term key as adding a passphrase unlocker does, with the vault's long-term key as adding a passphrase unlocker does, with the vault's
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
mnemonic, checked against the vault, or else from the current unlocker. checked against the vault, or else from the current unlocker. Before, it used
Before, it used the keychain unlocker's helper, which on every platform the keychain unlocker's helper, which on every platform but macOS always
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
key, getting the long-term key once from the mnemonic and once from a long-term key once from the mnemonic and once from a passphrase unlocker, and
passphrase unlocker, and reads a secret through the new unlocker. reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, - 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
`.`, `-` and `_`, and must not be empty, `.` or `..` `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` (https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
state the rule. `vault create`, `vault import`, `vault select`, the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
`vault remove`, both vault names of `mv` and shell completion of a vault names of `mv` and shell completion of a `vault:secret` argument check
`vault:secret` argument check the name as typed with the name as typed with `vault.ValidateVaultName` before building any path from
`vault.ValidateVaultName` before building any path from it. Before, it. Before, `vault import ..` wrote a long-term key and an unlocker into the
`vault import ..` wrote a long-term key and an unlocker into the state state directory itself, and `vault select ..` made that the current vault.
directory itself, and `vault select ..` made that the current vault. - 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged (https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the the `CHECK_EPOCH` build argument, which both the lint and the build stage of
current time as the `CHECK_EPOCH` build argument, which both the lint the `Dockerfile` declare after their module download, so the `RUN` steps below
and the build stage of the `Dockerfile` declare after their module the argument run again on each build while the base images and module
download, so the `RUN` steps below the argument run again on each downloads stay cached. Before, a second run on the same tree took every check
build while the base images and module downloads stay cached. Before, from the build cache and reported success having run nothing.
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 - 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48). directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for `secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
its duplicate check, and passes it to `CreatePGPUnlocker` to record. duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key `CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
and encrypt everything before writing anything. All four unlocker encrypt everything before writing anything. All four unlocker types write
types write their files through `secret.WriteDir`: a new unlocker is their files through `secret.WriteDir`: a new unlocker is built in a temporary
built in a temporary directory, renamed into place when complete and directory, renamed into place when complete and removed on a failure.
removed on a failure. - 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` the warning `unlocker list` gives, an unlocker directory whose metadata file
skip, with the warning `unlocker list` gives, an unlocker directory cannot be checked for, read or parsed, instead of failing when it sorts before
whose metadata file cannot be checked for, read or parsed, instead of the unlocker asked for. Such a directory, or one without a metadata file, is
failing when it sorts before the unlocker asked for. Such a directory, removed by its directory name, the name the warning gives; only the directory
or one without a metadata file, is removed by its directory name, the is removed, since its type is unknown. Removing one whose metadata file is
name the warning gives; only the directory is removed, since its type missing or corrupt never counts as removing the last unlocker. Removing one
is unknown. Removing one whose metadata file is missing or corrupt whose metadata file cannot be checked for or read always does, since it may be
never counts as removing the last unlocker. Removing one whose metadata the only working unlocker, so in a vault with secrets it needs `--force`.
file cannot be checked for or read always does, since it may be the - 2026-10-04: A failed command prints its error once, without the usage text
only working unlocker, so in a vault with secrets it needs `--force`. after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
- 2026-10-04: A failed command prints its error once, without the usage for a command called wrongly: wrong number of arguments, unknown flag, bad
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is flag value, missing required flag, or flags that break a flag group (mutually
still printed for a command called wrongly: wrong number of arguments, exclusive, required together, one required). The root command's
unknown flag, bad flag value, missing required flag, or flags that `PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
break a flag group (mutually exclusive, required together, one before that hook but required flags and flag groups only after it, so the hook
required). The root command's `PersistentPreRunE` turns usage off. checks those two first. Root `SilenceUsage` would have hidden usage for all of
Cobra checks arguments and flag values before that hook but required these.
flags and flag groups only after it, so the hook checks those two - 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
first. Root `SilenceUsage` would have hidden usage for all of these. out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
- 2026-10-04: `secret get` keeps the secret in locked memory until it `Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
writes it out (https://git.eeqj.de/sneak/secret/issues/37): destroys, and `secret get` writes its bytes straight to stdout, still with no
`Vault.GetSecret` and `Vault.GetSecretVersion` return a trailing newline. Before, the value was copied into ordinary memory that
`*memguard.LockedBuffer`, which every caller destroys, and `secret get` nothing wiped, and `get --version` also wrote it to the debug log.
writes its bytes straight to stdout, still with no trailing newline. - 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
Before, the value was copied into ordinary memory that nothing wiped, use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
and `get --version` also wrote it to the debug log. `make build` calls the new `script/build`, which stamps the version (`VERSION`
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker from the environment, else `git describe`) and the git commit as before.
targets use the local docker daemon, or whatever `DOCKER_HOST` the `build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
environment sets. `make build` calls the new `script/build`, which depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
stamps the version (`VERSION` from the environment, else first.
`git describe`) and the git commit as before. `build`, `clean`, - 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
`install` and `docker-run` are in `.PHONY`; `make install` depends on `.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
`build`. The `vet` target is gone: `script/test` runs `go vet` first. `/secret`, `*.log`, `*.test` and `settings.local.json`
- 2026-10-04: `.gitignore` is the org's standard file, which ignores (https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
`.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus `node_modules`; `.git` stays in the build context for the version stamp.
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 - 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already `secret vault create NAME` when `NAME` does, with "vault NAME already exists",
exists", before writing anything. The check is in `vault.CreateVault`, before writing anything. The check is in `vault.CreateVault`, which both
which both commands call while holding the state directory lock, so two commands call while holding the state directory lock, so two creates of one
creates of one vault at once cannot both pass the check. Before, either vault at once cannot both pass the check. Before, either command replaced the
command replaced the vault's metadata, passphrase unlocker and vault's metadata, passphrase unlocker and `longterm.age`, so none of its
`longterm.age`, so none of its secrets could be decrypted any more. Both secrets could be decrypted any more. Both commands now ask for the unlocker
commands now ask for the unlocker passphrase before creating the vault, passphrase before creating the vault, so one stopped at that prompt leaves no
so one stopped at that prompt leaves no vault behind. vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time - 2026-10-04: The `internal/cli` tests are back to about their time before the
before the state directory lock state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
(https://git.eeqj.de/sneak/secret/issues/80). The test that each that each changing command waits for the lock releases it as soon as it sees
changing command waits for the lock releases it as soon as it sees the the command waiting there, instead of after a fixed 100 ms. The two vaults
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
with passphrase unlockers that the path and move tests start from are once and copied for each test.
made once and copied for each test. - 2026-10-04: `secret mv` rejects a move whose destination is the source under
- 2026-10-04: `secret mv` rejects a move whose destination is the source another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
under another name, such as `foo` for `Foo` on a case-insensitive macOS default) or a name reached through a symbolic link, before changing
filesystem (the macOS default) or a name reached through a symbolic anything, with or without `--force`, within a vault and between vaults;
link, before changing anything, with or without `--force`, within a before, `--force` removed the destination and so deleted the secret. A rename
vault and between vaults; before, `--force` removed the destination and that changes only letter case works on a case-sensitive filesystem as before.
so deleted the secret. A rename that changes only letter case works on a - 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
case-sensitive filesystem as before. where golangci-lint is a build step rebuilt on every run
- 2026-10-04: Lint runs only in docker: `script/lint` builds (`--no-cache-filter`), so an unchanged tree is linted too; the module download
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on stays cached. `script/bootstrap` no longer installs golangci-lint, and the
every run (`--no-cache-filter`), so an unchanged tree is linted too; `Dockerfile` lint stage calls it directly instead of `make lint`.
the module download stays cached. `script/bootstrap` no longer `golangci-lint config verify` is not run: it fetches its schema live over
installs golangci-lint, and the `Dockerfile` lint stage calls it unpinned HTTPS.
directly instead of `make lint`. `golangci-lint config verify` is not - 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
run: it fetches its schema live over unpinned HTTPS. panics: `GetID()` warns with the unlocker's directory and returns
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID `pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
no longer panics: `GetID()` warns with the unlocker's directory and metadata file cannot be checked for, read or parsed instead of failing, so
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an `secret unlocker list` still lists the others; the listing's ID lookup no
unlocker whose metadata file cannot be checked for, read or parsed longer warns about that directory again.
instead of failing, so `secret unlocker list` still lists the others; - 2026-10-03: `secret mv` rejects a move whose destination is the source
the listing's ID lookup no longer warns about that directory again. (`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
- 2026-10-03: `secret mv` rejects a move whose destination is the defaults to the source name) before changing anything; before, `--force`
source (`mv --force x x`, `mv --force work:x work:`, or an empty removed the destination first and so deleted the secret. Every vault name
destination, which defaults to the source name) before changing given with `vault:` must be one of the existing vaults by exact name, so
anything; before, `--force` removed the destination first and so `work:x work/:x` is rejected instead of being taken for a move between two
deleted the secret. Every vault name given with `vault:` must be one vaults. A move within a named vault no longer makes that vault the current
of the existing vaults by exact name, so `work:x work/:x` is rejected one, whether it succeeds or fails.
instead of being taken for a move between two vaults. A move within a - 2026-10-03: Commands that change the state directory hold one lock (`flock` on
named vault no longer makes that vault the current one, whether it `lock` in the state directory; a mutex on the in-memory test filesystem), so
succeeds or fails. concurrent commands no longer lose versions or race on the current pointers.
- 2026-10-03: Commands that change the state directory hold one lock Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
(`flock` on `lock` in the state directory; a mutex on the in-memory rename), so no file is ever half-written and `current`, `currentvault` and
test filesystem), so concurrent commands no longer lose versions or `current-unlocker` never go missing. New versions, new secrets and cross-vault
race on the current pointers. Every file is written through copies are built in a temporary directory and renamed into place, and removals
`secret.WriteFileAtomic` (temporary file, sync, rename), so no file rename out of the way first, so a version or secret is never half-added and
is ever half-written and `current`, `currentvault` and never half-removed.
`current-unlocker` never go missing. New versions, new secrets and - 2026-10-03: The checks run before changing a vault now stop with an error
cross-vault copies are built in a temporary directory and renamed naming the path and cause when they cannot read what they inspect, instead of
into place, and removals rename out of the way first, so a version reading the failure as "nothing there": the duplicate check before
or secret is never half-added and never half-removed. `unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
- 2026-10-03: The checks run before changing a vault now stop with an the secret count that guards removing the last unlocker and removing a vault,
error naming the path and cause when they cannot read what they and the existing long-term key check before `vault import`.
inspect, instead of reading the failure as "nothing there": the - 2026-10-03: `version rm`, `version promote` and `get --version` accept a
duplicate check before `unlocker add pgp` (an unreadable version only if it is one of the versions `version list` lists for that
`unlockers.d` or unlocker metadata file), the secret count that secret, compared as typed before any path is built (`secret.VersionExists`),
guards removing the last unlocker and removing a vault, and the and touch nothing otherwise. An empty `--version` is rejected instead of
existing long-term key check before `vault import`. meaning the current version. Before, `secret version rm x ../../..` deleted
- 2026-10-03: `version rm`, `version promote` and `get --version` the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
accept a version only if it is one of the versions `version list` version.
lists for that secret, compared as typed before any path is built - 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
(`secret.VersionExists`), and touch nothing otherwise. An empty code after its deferred `memguard.Purge()` has run, and only `main` calls
`--version` is rejected instead of meaning the current version. `os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
Before, `secret version rm x ../../..` deleted the whole vault, buffer before exiting; when the process is in the terminal's foreground
`secret version rm x ..` the secret, and `.` or `""` every version. process group it first restores the terminal settings from startup, so an
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns interrupted passphrase prompt no longer leaves echo off.
the exit code after its deferred `memguard.Purge()` has run, and only - 2026-10-03: Every command that builds a path from a secret name checks the
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's name first with `vault.ValidateSecretName` and touches nothing when it is
handler, which wipes every buffer before exiting; when the process is invalid: `rm`, `mv` (both names, within a vault and between vaults, before
in the terminal's foreground process group it first restores the switching the current vault), `import`, `version list`/`promote`/`rm`,
terminal settings from startup, so an interrupted passphrase prompt no `encrypt` and `decrypt`. The error and `README.md` state the naming rule.
longer leaves echo off. Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
- 2026-10-03: Every command that builds a path from a secret name in it.
checks the name first with `vault.ValidateSecretName` and touches - 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
nothing when it is invalid: `rm`, `mv` (both names, within a vault it is generated into a locked buffer, and the keychain JSON is written and
and between vaults, before switching the current vault), `import`, read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
`version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error Linux) without `encoding/json` holding it; the JSON field names are unchanged.
and `README.md` state the naming rule. Before, `secret rm ..` - 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
deleted the whole vault and `secret rm .` every secret in it. that needs more locked memory than the process can lock, and run every case
- 2026-10-03: The keychain unlocker's age key passphrase stays in under `script/cibuild`. The image stamps the `VERSION` build argument, else
locked memory: it is generated into a locked buffer, and the `git describe --tags --always`, into `Version`, and fails if `.git` is present
keychain JSON is written and read by `KeychainData` code in but yields no version; `make build` stamps `git describe` too, not a fixed
`internal/secret/keychaindata.go` (tested on Linux) without `0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
`encoding/json` holding it; the JSON field names are unchanged.
- 2026-10-02: A plain `docker build .` builds again: the size tests
skip a case that needs more locked memory than the process can
lock, and run every case under `script/cibuild`. The image stamps the
`VERSION` build argument, else `git describe --tags --always`, into
`Version`, and fails if `.git` is present but yields no version;
`make build` stamps `git describe` too, not a fixed `0.1.0`.
`.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy. canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical - 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable `.golangci.yml` (all linters enabled minus the standard disable list, `lll`
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage 88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
image to the tagged v2.12.2 Debian digest; fixed all ~1550 new v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5` `pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
blank lines, sentinel errors for `err113`, `t.Parallel()` where `t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
safe, `_test` package conversions, complexity/`dupl` helper helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
extraction) on branch `golangci-v2.12.2`. Reworked after review: `err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
the `err113` sentinels in `internal/vault`, `internal/secret`, `pkg/bip85` were reshaped so every composed error message is byte-identical to
`internal/cli` and `pkg/bip85` were reshaped so every composed `main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
error message is byte-identical to `main`, and skips an unreadable `unlockers.d` entry with a warning instead of emitting a
`findUnlockerIDByMetadata` now returns an error so `unlocker list` fabricated fallback ID.
skips an unreadable `unlockers.d` entry with a warning instead of
emitting a fabricated fallback ID.
- 2026-08-07: Added `.editorconfig` - 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27). (https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
Makefile shims, README Entrypoints section shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target; - 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`. `.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and - 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the `.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256. `Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret - 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
protection, plus review fixes (stub panics, derivation index, tests, plus review fixes (stub panics, derivation index, tests, README) on branch
README) on branch secure-enclave-unlocker. secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out. - 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with - Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
missing metadata, allow uppercase secret names, fix hardcoded metadata, allow uppercase secret names, fix hardcoded derivation index,
derivation index, validate names in GetSecretVersion against path validate names in GetSecretVersion against path traversal, return errors
traversal, return errors instead of panicking, add Warn() on silent instead of panicking, add Warn() on silent anomalies.
anomalies. - Memory security hardening: LockedBuffer used through encrypt/decrypt paths
- Memory security hardening: LockedBuffer used through encrypt/decrypt (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated removed.
bare-[]byte APIs removed. - Per-secret keypair architecture, vault package refactor, versioning with
- Per-secret keypair architecture, vault package refactor, versioning --version, comprehensive test suite with in-memory filesystem.
with --version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output). - Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands. - Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, - 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
CLI).
# Future Steps # Future Steps
- Implement version-number shell completion for the second arg of - Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` `secret version promote` and `secret version rm` (`internal/cli/version.go`;
(`internal/cli/version.go`; was an in-code TODO removed for godox). was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in - Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an `TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
in-code FIXME removed for godox). - CI does not compile, lint or test the files built only with cgo on macOS,
- CI does not compile, lint or test the files built only with cgo on since compiling them needs Apple's SDK:
macOS, since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call `internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse` `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
never run on them, so it would likely find more there than the line run on them, so it would likely find more there than the line lengths. No
lengths. No macOS test runs in CI. A macOS runner would cover all of it macOS test runs in CI. A macOS runner would cover all of it (asked on
(asked on https://git.eeqj.de/sneak/secret/issues/50). https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in - Memory security: age writes an identity's private key out as a string in
ordinary memory, and the copies it makes on the way stay there ordinary memory, and the copies it makes on the way stay there
(`secret.IdentityToLockedBuffer` overwrites only the string itself). (`secret.IdentityToLockedBuffer` overwrites only the string itself).
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Graceful handling of corrupted or missing key files with recovery
suggestions.
- Split oversized CLI functions. - Split oversized CLI functions.
- Cleanups: read statedir from environment or default instead of - Cleanups: read statedir from environment or default instead of passing it
passing it around. around.
- Enhancements: help examples, colored output, --quiet flag, name suggestions on - Enhancements: help examples, colored output, --quiet flag, name suggestions on
miss, audit logging, hardware integration tests (Keychain, GPG), naming miss, audit logging, hardware integration tests (Keychain, GPG), naming
consistency, vault export/import, batch operations, search, secret metadata consistency, vault export/import, batch operations, search, secret metadata
+1 -1
View File
@@ -4,7 +4,7 @@ package main
import ( import (
"os" "os"
"git.eeqj.de/sneak/secret/internal/cli" "sneak.berlin/go/secret/internal/cli"
) )
func main() { func main() {
+4 -4
View File
@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/secret module sneak.berlin/go/secret
go 1.24.1 go 1.24.1
@@ -9,7 +9,9 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal
github.com/dustin/go-humanize v1.0.1
github.com/fatih/color v1.18.0
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
@@ -26,8 +28,6 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
+2 -2
View File
@@ -35,8 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+1 -1
View File
@@ -6,10 +6,10 @@ import (
"io" "io"
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
) )
// Instance encapsulates all CLI functionality and state // Instance encapsulates all CLI functionality and state
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
func TestCLIInstanceStateDir(t *testing.T) { func TestCLIInstanceStateDir(t *testing.T) {
+7 -30
View File
@@ -1,13 +1,13 @@
package cli package cli
import ( import (
"path/filepath" "maps"
"slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// getSecretNamesCompletionFunc returns a completion function that provides // getSecretNamesCompletionFunc returns a completion function that provides
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
} }
// getUnlockerIDsCompletionFunc returns a completion function that provides // getUnlockerIDsCompletionFunc returns a completion function that provides
// unlocker IDs // unlocker IDs, the names of the unlockers' directories in unlockers.d
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func( func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string, cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) { ) ([]string, cobra.ShellCompDirective) {
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
} }
// Get unlocker metadata list unlockerMetadata, err := vlt.ListUnlockers()
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
} }
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string var completions []string
unlockersDir := filepath.Join(vaultDir, "unlockers.d") for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
if strings.HasPrefix(id, toComplete) {
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
id, err := findUnlockerIDByMetadata(
fs, unlockersDir, metadata, false,
)
if err != nil {
secret.Warn(
"Could not read unlockers directory during completion, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
if id != "" && strings.HasPrefix(id, toComplete) {
completions = append(completions, id) completions = append(completions, id)
} }
} }
+1 -1
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
"sneak.berlin/go/secret/internal/vault"
) )
// Sentinel errors for asking the user to confirm a removal // Sentinel errors for asking the user to confirm a removal
+8 -7
View File
@@ -24,12 +24,12 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -101,7 +101,8 @@ func newRemoval(t *testing.T, command string) removal {
} }
fs, workDir, older := newConfirmTestVaults(t, unlockers) fs, workDir, older := newConfirmTestVaults(t, unlockers)
unlockerID := "pgp-" + listTestGPGKeyID + "A" // The first unlocker's directory name, written by newConfirmTestVaults
unlockerID := "pgp-0"
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error { removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.UnlockersRemove(unlockerID, force, cmd) return cli.UnlockersRemove(unlockerID, force, cmd)
@@ -142,7 +143,7 @@ func newRemoval(t *testing.T, command string) removal {
return removal{ return removal{
fs: fs, fs: fs,
run: removeFirstUnlocker, run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"), removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID + question: "Permanently remove unlocker '" + unlockerID +
"' from vault 'work'? It is not the vault's last unlocker.", "' from vault 'work'? It is not the vault's last unlocker.",
} }
@@ -150,7 +151,7 @@ func newRemoval(t *testing.T, command string) removal {
return removal{ return removal{
fs: fs, fs: fs,
run: removeFirstUnlocker, run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"), removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID + question: "Permanently remove unlocker '" + unlockerID +
"', the last unlocker of vault 'work', which holds 1 " + "', the last unlocker of vault 'work', which holds 1 " +
"secret(s)? Without an unlocker the vault opens only " + "secret(s)? Without an unlocker the vault opens only " +
@@ -352,9 +353,9 @@ func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
// for its answer, another command can take the state directory lock and // for its answer, another command can take the state directory lock and
// change the secret, and that the removal then removes nothing, since the // change the secret, and that the removal then removes nothing, since the
// secret is no longer what the question named. // secret is no longer what the question named.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestRemovalAsksWithoutHoldingLock(t *testing.T) { func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
t.Parallel()
r := newRemoval(t, "rm") r := newRemoval(t, "rm")
answers, answerWriter := io.Pipe() answers, answerWriter := io.Pipe()
+43 -17
View File
@@ -5,17 +5,18 @@ import (
"io" "io"
"maps" "maps"
"os" "os"
"os/exec"
"slices" "slices"
"strings" "strings"
"testing" "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/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestCreateExistingVaultChangesNothing is a regression test for // TestCreateExistingVaultChangesNothing is a regression test for
@@ -66,22 +67,18 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
tests := []struct { tests := []struct {
command string command string
want string
run func(c *cli.Instance) error run func(c *cli.Instance) error
}{ }{
{ {
"init", "init",
"failed to create default vault: vault default already exists",
func(c *cli.Instance) error { return c.Init(cmd) }, func(c *cli.Instance) error { return c.Init(cmd) },
}, },
{ {
"vault create default", "vault create default",
"vault default already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") }, func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
}, },
{ {
"vault create work", "vault create work",
"vault work already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }, func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
}, },
} }
@@ -92,7 +89,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
err := tt.run(newCLI(fs)) err := tt.run(newCLI(fs))
require.EqualError(t, err, tt.want) require.ErrorIs(t, err, vault.ErrVaultExists)
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
}) })
} }
@@ -192,12 +189,43 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
err := tt.run(c) err := tt.run(c)
require.ErrorContains(t, err, "failed to read passphrase") require.ErrorIs(t, err, secret.ErrPassphraseNotRead)
require.Equal(t, before, snapshotStateDir(t, tt.fs)) require.Equal(t, before, snapshotStateDir(t, tt.fs))
}) })
} }
} }
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
// read mnemonic: failed to read passphrase: ...". The error must wrap
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
// pinned on the built binary, whose stdin is surely not a terminal.
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
t.Parallel()
c := cli.NewCLIInstanceWithStateDir(afero.NewMemMapFs(), testStateDir)
require.ErrorIs(t, c.Init(discardCmd()), secret.ErrMnemonicNotRead)
stateDir := t.TempDir()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "init")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
"or run interactively\n", string(output))
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for // TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault // https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was // create` killed after the passphrase prompt but before the unlocker was
@@ -227,9 +255,7 @@ func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms)) require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
requireStopsLeaveWholeVaultOrNone(t, fs, "default", requireStopsLeaveWholeVaultOrNone(t, fs, "default", mnemonic, passphrase,
"failed to create default vault: vault default already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.Init(cmd) }) func(c *cli.Instance) error { return c.Init(cmd) })
}) })
@@ -239,8 +265,7 @@ func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil) _, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists", requireStopsLeaveWholeVaultOrNone(t, fs, "work", mnemonic, passphrase,
mnemonic, passphrase,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }) func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
}) })
} }
@@ -248,9 +273,10 @@ func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
// requireStopsLeaveWholeVaultOrNone checks, as // requireStopsLeaveWholeVaultOrNone checks, as
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the // TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
// command run, creating the vault name on fs with mnemonic and passphrase. // command run, creating the vault name on fs with mnemonic and passphrase.
// Run again where the vault is there, the command must fail with exists. // Run again where the vault is there, the command must fail with
// vault.ErrVaultExists.
func requireStopsLeaveWholeVaultOrNone( func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name, exists string, t *testing.T, fs afero.Fs, name string,
mnemonic, passphrase *memguard.LockedBuffer, mnemonic, passphrase *memguard.LockedBuffer,
run func(c *cli.Instance) error, run func(c *cli.Instance) error,
) { ) {
@@ -312,7 +338,7 @@ func requireStopsLeaveWholeVaultOrNone(
c.UnlockPassphrase = passphrase c.UnlockPassphrase = passphrase
if _, there := state[vaultDir+"/"]; there { if _, there := state[vaultDir+"/"]; there {
require.EqualError(t, run(c), exists) require.ErrorIs(t, run(c), vault.ErrVaultExists)
} else { } else {
require.NoError(t, run(c)) require.NoError(t, run(c))
} }
+5 -23
View File
@@ -7,17 +7,16 @@ import (
"os" "os"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Sentinel errors for encrypt/decrypt operations // Sentinel errors for encrypt/decrypt operations
var ( var (
errNotAgeSecretKey = errors.New( errNotAgeSecretKey = errors.New(
"does not contain a valid age secret key") "does not contain a valid age secret key")
errSecretDoesNotExist = errors.New("does not exist")
) )
// newCryptoCmd builds an encrypt/decrypt command with input/output flags // newCryptoCmd builds an encrypt/decrypt command with input/output flags
@@ -130,7 +129,7 @@ func (cli *Instance) resolveEncryptionKey(
} }
// Secret exists, get the age secret key from it // Secret exists, get the age secret key from it
secretBuffer, err := cli.getSecretValue(vlt, secretObj) secretBuffer, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get secret value: %w", err) return nil, fmt.Errorf("failed to get secret value: %w", err)
} }
@@ -245,11 +244,11 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist) return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
} }
// Get the age secret key from the secret // Get the age secret key from the secret
secretBuffer, err := cli.getSecretValue(vlt, secretObj) secretBuffer, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)
} }
@@ -313,20 +312,3 @@ func isValidAgeSecretKey(key string) bool {
return err == nil return err == nil
} }
// getSecretValue retrieves the value of a secret with the vault's mnemonic
// when it has one, else with the current unlocker
func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) {
if vlt.Mnemonic != nil {
return secretObj.GetValue(nil, vlt.Mnemonic)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker, nil)
}
+3 -4
View File
@@ -8,13 +8,12 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry must return its exit code rather than exit, so that its deferred // Entry must return its exit code rather than exit, so that its deferred
@@ -52,7 +51,7 @@ func TestInterruptExitsThroughMemguard(t *testing.T) {
const waitingForValue = "Reading secret value from stdin" const waitingForValue = "Reading secret value from stdin"
ctx, cancel := context.WithTimeout(t.Context(), time.Minute) ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel() defer cancel()
wd, err := filepath.Abs("../..") wd, err := filepath.Abs("../..")
+62
View File
@@ -0,0 +1,62 @@
package cli_test
import (
"testing"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
)
// TestMissingSecretOrVaultErrors checks that a command that finds no such
// secret or vault returns the vault package's error for it, as `secret get`
// does, and leaves the vaults unchanged. "default" is the current vault, and
// both vaults hold the secret "x".
func TestMissingSecretOrVaultErrors(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
tests := []struct {
command string
want error
run func(c *cli.Instance) error
}{
{
"rm --force nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.RemoveSecret(&cobra.Command{}, "nosuch", true)
},
},
{
"version rm --force nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.RemoveVersion(&cobra.Command{}, "nosuch", "20260101.001", true)
},
},
{
"mv --force work:nosuch default", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, "work:nosuch", "default", true)
},
},
{
"decrypt nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error { return c.Decrypt("nosuch", "", "") },
},
{
"vault rm --force nosuch", vault.ErrVaultNotFound,
func(c *cli.Instance) error {
return c.RemoveVault(&cobra.Command{}, "nosuch", true)
},
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, tt.want, tt.run)
})
}
}
+5 -8
View File
@@ -7,10 +7,10 @@ import (
"math/big" "math/big"
"os" "os"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -21,10 +21,6 @@ const (
// Sentinel errors for secret generation // Sentinel errors for secret generation
var ( var (
errLengthTooSmall = errors.New("length must be at least 1") errLengthTooSmall = errors.New("length must be at least 1")
errLengthNotPositive = errors.New("length must be positive")
errMnemonicTypeNotSupported = errors.New(
"mnemonic type not supported for secret generation, " +
"use 'secret generate mnemonic' instead")
errUnsupportedSecretType = errors.New("unsupported type") errUnsupportedSecretType = errors.New("unsupported type")
) )
@@ -148,7 +144,8 @@ func (cli *Instance) GenerateSecret(
case "alnum": case "alnum":
secretValue, err = generateRandomAlnum(length) secretValue, err = generateRandomAlnum(length)
case "mnemonic": case "mnemonic":
return errMnemonicTypeNotSupported return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
errUnsupportedSecretType)
default: default:
return fmt.Errorf("%w: %s (supported: base58, alnum)", return fmt.Errorf("%w: %s (supported: base58, alnum)",
errUnsupportedSecretType, secretType) errUnsupportedSecretType, secretType)
@@ -204,8 +201,8 @@ func generateRandomAlnum(length int) (string, error) {
// generateRandomString generates a random string of the specified length // generateRandomString generates a random string of the specified length
// using the given character set // using the given character set
func generateRandomString(length int, charset string) (string, error) { func generateRandomString(length int, charset string) (string, error) {
if length <= 0 { if length < 1 {
return "", errLengthNotPositive return "", errLengthTooSmall
} }
result := make([]byte, length) result := make([]byte, length)
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// Version info - these are set at build time // Version info - these are set at build time
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// vaultStats accumulates statistics while walking vault directories // vaultStats accumulates statistics while walking vault directories
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"os" "os"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// errPassphraseMismatch is returned when passphrase confirmation fails // errPassphraseMismatch is returned when passphrase confirmation fails
@@ -55,11 +55,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo // Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ") mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
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, nil, err
} }
fmt.Fprintln(os.Stderr) // Add newline after hidden input fmt.Fprintln(os.Stderr) // Add newline after hidden input
+67
View File
@@ -0,0 +1,67 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
)
// TestInvalidMnemonicError checks that every command that takes a mnemonic
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
// "other" has no long-term key, as vault import needs.
func TestInvalidMnemonicError(t *testing.T) {
t.Parallel()
tests := []struct {
command string
run func(c *Instance) error
}{
{"secret init", func(c *Instance) error { return c.Init(c.cmd) }},
{"secret vault create work", func(c *Instance) error {
return c.CreateVault(c.cmd, "work")
}},
{"secret vault import other", func(c *Instance) error {
return c.VaultImport(c.cmd, "other")
}},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
require.NoError(t, err)
instance, _ := newTestInstance(fs)
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
t.Cleanup(instance.Mnemonic.Destroy)
require.ErrorIs(t, tt.run(instance), errInvalidMnemonicPhrase)
})
}
}
// TestGenerateSecretErrors checks that `secret generate secret` gives one
// error for a length below 1 and one for a type it cannot generate.
func TestGenerateSecretErrors(t *testing.T) {
t.Parallel()
instance, cmd := newTestInstance(afero.NewMemMapFs())
err := instance.GenerateSecret(cmd, "x", 0, "base58", false)
require.ErrorIs(t, err, errLengthTooSmall)
_, err = generateRandomString(0, "ab")
require.ErrorIs(t, err, errLengthTooSmall)
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "mnemonic", false)
require.ErrorIs(t, err, errUnsupportedSecretType)
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "hex", false)
require.ErrorIs(t, err, errUnsupportedSecretType)
}
+77 -66
View File
@@ -17,21 +17,27 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
// testMnemonic is a standard BIP39 mnemonic used for testing // testMnemonic is a standard BIP39 mnemonic used for testing
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word //nolint:dupword // BIP39 test mnemonic intentionally repeats a word
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"
// commandWait is how long a test lets the secret binary run before it
// kills it and fails. It stays well under the 30 seconds script/test
// gives the whole package, so a command that hangs fails the test with
// the test's own message instead of Go's timeout panic.
commandWait = 10 * time.Second
) )
// errEmptyValue indicates a concurrent reader received an empty secret value. // errEmptyValue indicates a concurrent reader received an empty secret value.
@@ -52,8 +58,12 @@ func runSecretWithStdin(stdin string, env map[string]string, args ...string) (st
return cli.ExecuteCommandInProcess(args, stdin, env) return cli.ExecuteCommandInProcess(args, stdin, env)
} }
// TestMain runs before all tests and ensures the binary is built // TestMain runs before all tests and ensures the binary is built. It also
// makes passphrase encryption in the tests cheap (see
// secret.ScryptWorkFactor); the binary keeps age's work factor.
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
secret.ScryptWorkFactor = 1
// Get the current working directory // Get the current working directory
wd, err := os.Getwd() wd, err := os.Getwd()
if err != nil { if err != nil {
@@ -680,10 +690,10 @@ func test06GetSecret(t *testing.T, testMnemonic string, runSecret func(...string
require.NoError(t, err, "get secret should succeed") require.NoError(t, err, "get secret should succeed")
assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value") assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value")
// Test that without mnemonic, we get an error // Test that without mnemonic, we get an error: the passphrase unlocker
output, err = runSecret("get", "database/password") // cannot ask for its passphrase, as the tests have no terminal
require.Error(t, err, "get should fail without unlock method") _, err = runSecret("get", "database/password")
assert.Contains(t, output, "failed to unlock vault", "should indicate unlock failure") require.ErrorIs(t, err, secret.ErrPassphraseNotRead, "get should fail without unlock method")
} }
func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
@@ -839,12 +849,11 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
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 // An empty --version is not a version; it does not mean the current one
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "", "database/password") }, "get", "--version", "", "database/password")
require.Error(t, err, "get with an empty version should fail") require.ErrorIs(t, err, vault.ErrVersionNotFound, "should reject the empty version")
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)) {
@@ -1158,11 +1167,7 @@ func testInvalidSecretNames(t *testing.T, testMnemonic string, runSecretWithStdi
shouldFail := slices.Contains(definitelyInvalid, invalidName) shouldFail := slices.Contains(definitelyInvalid, invalidName)
if shouldFail { if shouldFail {
require.Error(t, err, "add '%s' should fail", invalidName) require.ErrorIs(t, err, vault.ErrInvalidSecretName, "add '%s' should fail", invalidName)
if err != nil {
assert.Contains(t, output, "invalid secret name", "should indicate invalid name for '%s'", invalidName)
}
} else { } else {
// For the slash cases and .hidden, they might succeed // For the slash cases and .hidden, they might succeed
// Just log what happened // Just log what happened
@@ -1221,9 +1226,8 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
// Test error cases // Test error cases
// Try to move non-existent secret // Try to move non-existent secret
output, err = runSecret("move", "test/nonexistent", "test/destination") _, err = runSecret("move", "test/nonexistent", "test/destination")
require.Error(t, err, "move non-existent should fail") require.ErrorIs(t, err, vault.ErrSecretNotFound, "move non-existent should fail")
assert.Contains(t, output, "not found", "should indicate source not found")
// Try to move to existing destination // Try to move to existing destination
_, err = runSecretWithStdin("dest-value", map[string]string{ _, err = runSecretWithStdin("dest-value", map[string]string{
@@ -1231,9 +1235,8 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
}, "add", "test/existing-dest") }, "add", "test/existing-dest")
require.NoError(t, err, "add test/existing-dest should succeed") require.NoError(t, err, "add test/existing-dest should succeed")
output, err = runSecret("move", "test/renamed", "test/existing-dest") _, err = runSecret("move", "test/renamed", "test/existing-dest")
require.Error(t, err, "move to existing destination should fail") require.ErrorIs(t, err, vault.ErrSecretExists, "move to existing destination should fail")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Verify the source wasn't removed since move failed // Verify the source wasn't removed since move failed
getOutput, err = runSecretWithEnv(map[string]string{ getOutput, err = runSecretWithEnv(map[string]string{
@@ -1310,9 +1313,8 @@ func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv f
require.NoError(t, err, "add force/test in work should succeed") require.NoError(t, err, "add force/test in work should succeed")
// Move without force should fail // Move without force should fail
output, err = runSecretWithEnv(env, "move", "work:force/test", "default") _, err = runSecretWithEnv(env, "move", "work:force/test", "default")
require.Error(t, err, "move without force should fail when dest exists") require.ErrorIs(t, err, vault.ErrSecretExists, "move without force should fail when dest exists")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Move with force should succeed // Move with force should succeed
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default") output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
@@ -1427,9 +1429,8 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Test selecting non-existent vault // Test selecting non-existent vault
output, err := runSecret("vault", "select", "nonexistent") _, err = runSecret("vault", "select", "nonexistent")
require.Error(t, err, "selecting non-existent vault should fail") require.ErrorIs(t, err, vault.ErrVaultNotFound, "selecting non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
} }
func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
@@ -1450,11 +1451,10 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
require.NoError(t, err, "vault select work should succeed") require.NoError(t, err, "vault select work should succeed")
// Try to get the default-only secret (should fail) // Try to get the default-only secret (should fail)
output, err := runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "default-only/secret") }, "get", "default-only/secret")
require.Error(t, err, "should not be able to get default vault secret from work vault") require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get default vault secret from work vault")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Add a unique secret to work vault // Add a unique secret to work vault
_, err = runSecretWithStdin("work-vault-secret", map[string]string{ _, err = runSecretWithStdin("work-vault-secret", map[string]string{
@@ -1467,14 +1467,13 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Try to get the work-only secret (should fail) // Try to get the work-only secret (should fail)
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "work-only/secret") }, "get", "work-only/secret")
require.Error(t, err, "should not be able to get work vault secret from default vault") require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get work vault secret from default vault")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Verify we can still get the default-only secret // Verify we can still get the default-only secret
output, err = runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "default-only/secret") }, "get", "default-only/secret")
require.NoError(t, err, "get default-only secret should succeed") require.NoError(t, err, "get default-only secret should succeed")
@@ -1586,11 +1585,10 @@ func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretW
// Just verify the import succeeded // Just verify the import succeeded
// Test importing non-existent file // Test importing non-existent file
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "import", "imported/nonexistent", "--source", "/nonexistent/file") }, "import", "imported/nonexistent", "--source", "/nonexistent/file")
require.Error(t, err, "importing non-existent file should fail") require.ErrorIs(t, err, os.ErrNotExist, "importing non-existent file should fail")
assert.Contains(t, output, "failed", "should indicate failure")
// Verify filesystem structure // Verify filesystem structure
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default") defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
@@ -1905,11 +1903,10 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
t.Helper() t.Helper()
// Get non-existent secret // Get non-existent secret
output, err := runSecretWithEnv(map[string]string{ _, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "nonexistent/secret") }, "get", "nonexistent/secret")
require.Error(t, err, "get non-existent secret should fail") require.ErrorIs(t, err, vault.ErrSecretNotFound, "get non-existent secret should fail")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Add secret without mnemonic or unlocker // Add secret without mnemonic or unlocker
unsetMnemonic := os.Getenv(secret.EnvMnemonic) unsetMnemonic := os.Getenv(secret.EnvMnemonic)
@@ -1939,32 +1936,28 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
// Invalid secret names (already tested in test 12) // Invalid secret names (already tested in test 12)
// Non-existent vault operations // Non-existent vault operations
output, err = runSecret("vault", "select", "nonexistent") _, err = runSecret("vault", "select", "nonexistent")
require.Error(t, err, "select non-existent vault should fail") require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
// Import to non-existent vault with test passphrase // Import to non-existent vault with test passphrase
testPassphrase := "test-passphrase-123" // Define testPassphrase locally testPassphrase := "test-passphrase-123" // Define testPassphrase locally
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
secret.EnvUnlockPassphrase: testPassphrase, secret.EnvUnlockPassphrase: testPassphrase,
}, "vault", "import", "nonexistent") }, "vault", "import", "nonexistent")
require.Error(t, err, "import to non-existent vault should fail") require.ErrorIs(t, err, vault.ErrVaultNotFound, "import to non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
// Get specific version that doesn't exist // Get specific version that doesn't exist
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "99999999.999", "database/password") }, "get", "--version", "99999999.999", "database/password")
require.Error(t, err, "get non-existent version should fail") require.ErrorIs(t, err, vault.ErrVersionNotFound, "get non-existent version should fail")
assert.Contains(t, output, "not found", "should indicate version not found")
// Promote non-existent version // Promote non-existent version
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "version", "promote", "database/password", "99999999.999") }, "version", "promote", "database/password", "99999999.999")
require.Error(t, err, "promote non-existent version should fail") require.ErrorIs(t, err, vault.ErrVersionNotFound, "promote non-existent version should fail")
assert.Contains(t, output, "not found", "should indicate version not found")
} }
func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) { func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) {
@@ -2367,11 +2360,10 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
assert.NotEmpty(t, output, "restored secret should have value") assert.NotEmpty(t, output, "restored secret should have value")
// Verify post-backup secret is gone // Verify post-backup secret is gone
output, err = runSecretWithEnv(map[string]string{ _, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "post-backup/secret") }, "get", "post-backup/secret")
require.Error(t, err, "post-backup secret should not exist after restore") require.ErrorIs(t, err, vault.ErrSecretNotFound, "post-backup secret should not exist after restore")
assert.Contains(t, output, "not found", "should indicate secret not found")
t.Log("Backup and restore completed successfully") t.Log("Backup and restore completed successfully")
} }
@@ -2436,8 +2428,7 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
t.Logf("Output: %s", getOutput) t.Logf("Output: %s", getOutput)
// This is the expected behavior with the current bug // This is the expected behavior with the current bug
require.Error(t, err, "get should fail due to wrong derivation index") require.ErrorIs(t, err, vault.ErrMnemonicMismatch, "get should fail due to wrong derivation index")
assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
// Document what should happen when the bug is fixed // Document what should happen when the bug is fixed
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1") t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
@@ -2598,7 +2589,7 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
_ = stdin.Close() _ = stdin.Close()
}() }()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute) ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel() defer cancel()
cmd, secretDir := secretRmCommand(ctx, t) cmd, secretDir := secretRmCommand(ctx, t)
@@ -2616,7 +2607,13 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
// and stderr, not both: whether it asks must depend on stdin alone, where // and stderr, not both: whether it asks must depend on stdin alone, where
// the answer is read from. pty.Open returns the two ends of a new terminal: // the answer is read from. pty.Open returns the two ends of a new terminal:
// tty is the end a program uses as its terminal, and ptmx the end the test // tty is the end a program uses as its terminal, and ptmx the end the test
// reads what the terminal shows from and types into. // reads what the terminal shows from and types into. Reading ptmx stops at
// the context's deadline, commandWait (10 seconds) after the test starts,
// when secret rm is killed too, so a terminal that stays open fails the test
// then with its own message instead of hanging it. The deadline works only
// while ptmx stays non-blocking, as pty.Open of the github.com/creack/pty
// commit in go.mod leaves it: calling ptmx.Fd() or going back to v1.1.24
// makes the read ignore the deadline, without any error.
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a // TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
// terminal. stdin is a pipe, so nobody can answer there, and the command // terminal. stdin is a pipe, so nobody can answer there, and the command
@@ -2624,7 +2621,7 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) { func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute) ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel() defer cancel()
cmd, secretDir := secretRmCommand(ctx, t) cmd, secretDir := secretRmCommand(ctx, t)
@@ -2634,6 +2631,9 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
defer func() { _ = ptmx.Close() }() defer func() { _ = ptmx.Close() }()
deadline, _ := ctx.Deadline()
require.NoError(t, ptmx.SetReadDeadline(deadline))
cmd.Stdin = strings.NewReader("y\n") cmd.Stdin = strings.NewReader("y\n")
cmd.Stdout = tty cmd.Stdout = tty
cmd.Stderr = tty cmd.Stderr = tty
@@ -2643,9 +2643,16 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
_ = tty.Close() _ = tty.Close()
// The read ends once secret rm has exited and so closed the terminal. // The read ends once secret rm has exited and so closed the terminal.
shown, _ := io.ReadAll(ptmx) shown, err := io.ReadAll(ptmx)
require.NotErrorIs(t, err, os.ErrDeadlineExceeded,
"the terminal was still open %s after secret rm started: %s",
commandWait, shown)
require.Error(t, cmd.Wait()) err = cmd.Wait()
require.NoError(t, ctx.Err(), "secret rm did not exit within %s",
commandWait)
require.Error(t, err)
assert.Contains(t, string(shown), "pass --force") assert.Contains(t, string(shown), "pass --force")
assert.DirExists(t, secretDir) assert.DirExists(t, secretDir)
} }
@@ -2655,7 +2662,7 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) { func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute) ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel() defer cancel()
cmd, secretDir := secretRmCommand(ctx, t) cmd, secretDir := secretRmCommand(ctx, t)
@@ -2665,6 +2672,9 @@ func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
defer func() { _ = ptmx.Close() }() defer func() { _ = ptmx.Close() }()
deadline, _ := ctx.Deadline()
require.NoError(t, ptmx.SetReadDeadline(deadline))
cmd.Stdin = tty cmd.Stdin = tty
// Not a file, so exec.Cmd connects stdout through a pipe. // Not a file, so exec.Cmd connects stdout through a pipe.
cmd.Stdout = io.Discard cmd.Stdout = io.Discard
@@ -2682,7 +2692,8 @@ func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
terminal := bufio.NewReader(ptmx) terminal := bufio.NewReader(ptmx)
for !bytes.HasSuffix(shown, []byte("[y/N] ")) { for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
char, err = terminal.ReadByte() char, err = terminal.ReadByte()
require.NoError(t, err, "secret rm ended without asking: %s", shown) require.NoError(t, err, "secret rm did not ask on the terminal: %s",
shown)
shown = append(shown, char) shown = append(shown, char)
} }
+3 -3
View File
@@ -4,12 +4,12 @@ import (
"io" "io"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestLeftoversRemovedByNextChangingCommand is a regression test for // TestLeftoversRemovedByNextChangingCommand is a regression test for
+8 -6
View File
@@ -13,13 +13,13 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -94,9 +94,9 @@ func numbered(prefix string, count int) []string {
// lock, adds of a new secret all find it absent and replace each other, and // 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 // forced adds read the same highest version number and overwrite each
// other's version. With it they behave as if run one after another. // 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) { func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Parallel()
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
const adds = 8 const adds = 8
@@ -110,6 +110,8 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()}, {"real", afero.NewOsFs(), t.TempDir()},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil) _, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
@@ -235,9 +237,9 @@ func TestEncryptPipedIntoAdd(t *testing.T) {
// TestFailedCommandReleasesLock checks that a command failing after it // TestFailedCommandReleasesLock checks that a command failing after it
// took the state directory lock leaves the lock free for the next command. // took the state directory lock leaves the lock free for the next command.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestFailedCommandReleasesLock(t *testing.T) { func TestFailedCommandReleasesLock(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
+51 -26
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
) )
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for // TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
@@ -30,6 +30,8 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
workX = "work:x" workX = "work:x"
) )
// internal/cli declares these errors itself and does not export them, so
// only their text can be compared.
tests := []struct { tests := []struct {
command string command string
source, dest string source, dest string
@@ -43,30 +45,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
{`mv --force work:x ""`, workX, "", true, ontoItself}, {`mv --force work:x ""`, workX, "", true, ontoItself},
// "work" is a vault name, so the destination is work:x. // "work" is a vault name, so the destination is work:x.
{"mv --force work:x work", workX, "work", true, ontoItself}, {"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 { for _, tt := range tests {
@@ -82,6 +60,53 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
require.EqualError(t, err, tt.wantErr) require.EqualError(t, err, tt.wantErr)
}) })
} }
missing := []struct {
command string
source, dest string
force bool
want error
}{
{
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
vault.ErrSecretNotFound,
},
// Only an existing vault is used.
{
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
vault.ErrVaultNotFound,
},
}
for _, tt := range missing {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, tt.want, func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
})
})
}
// 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.
invalidNames := []struct{ source, dest string }{
{workX, "work/:x"},
{"work/:x", "work:"},
{workX, "./work:x"},
}
for _, tt := range invalidNames {
t.Run("mv --force "+tt.source+" "+tt.dest, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, true)
})
})
}
} }
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x // TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
+31 -35
View File
@@ -9,13 +9,13 @@ import (
"sync" "sync"
"testing" "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/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -154,11 +154,10 @@ 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 the error want, so that a later
// message of want, so that a later check rejecting the argument does not // check rejecting the argument does not count, and everything under the
// count, and everything under the state directory as it was: the error // state directory as it was: the error alone proves nothing, since it could
// alone proves nothing, since it could come after the vault had already // come after the vault had already been deleted.
// been deleted.
func requireRejectedAndUnchanged( func requireRejectedAndUnchanged(
t *testing.T, before map[string]string, want error, t *testing.T, before map[string]string, want error,
run func(c *cli.Instance) error, run func(c *cli.Instance) error,
@@ -170,7 +169,7 @@ 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, want)
} }
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for // TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
@@ -194,76 +193,75 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
tests := []struct { tests := []struct {
command string command string
rejected string // the secret name the command must reject
run func(c *cli.Instance) error run func(c *cli.Instance) error
}{ }{
{"rm --force ..", "..", func(c *cli.Instance) error { {"rm --force ..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", true) return c.RemoveSecret(cmd, "..", true)
}}, }},
{"rm --force .", ".", func(c *cli.Instance) error { {"rm --force .", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", true) return c.RemoveSecret(cmd, ".", true)
}}, }},
{`rm --force ""`, "", func(c *cli.Instance) error { {`rm --force ""`, func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", true) return c.RemoveSecret(cmd, "", true)
}}, }},
{"rm --force ../../etc", "../../etc", func(c *cli.Instance) error { {"rm --force ../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", true) return c.RemoveSecret(cmd, "../../etc", true)
}}, }},
{"mv --force .. x", "..", func(c *cli.Instance) error { {"mv --force .. x", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "..", "x", true) return c.MoveSecret(cmd, "..", "x", true)
}}, }},
{"mv --force x ..", "..", func(c *cli.Instance) error { {"mv --force x ..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "x", "..", true) return c.MoveSecret(cmd, "x", "..", true)
}}, }},
{`mv --force x ""`, "", func(c *cli.Instance) error { {`mv --force x ""`, func(c *cli.Instance) error {
return c.MoveSecret(cmd, "x", "", true) return c.MoveSecret(cmd, "x", "", true)
}}, }},
// "work" is not the current vault: a move within it must not // "work" is not the current vault: a move within it must not
// select it when a name is rejected. // select it when a name is rejected.
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error { {"mv --force work:.. work:x", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:..", "work:x", true) return c.MoveSecret(cmd, "work:..", "work:x", true)
}}, }},
{"mv --force work:x work:..", "..", func(c *cli.Instance) error { {"mv --force work:x work:..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:x", "work:..", true) return c.MoveSecret(cmd, "work:x", "work:..", true)
}}, }},
{"mv --force default:.. work", "..", func(c *cli.Instance) error { {"mv --force default:.. work", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work", true) return c.MoveSecret(cmd, "default:..", "work", true)
}}, }},
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error { {"mv --force default:.. work:y", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work:y", true) return c.MoveSecret(cmd, "default:..", "work:y", true)
}}, }},
{"mv --force default:x work:..", "..", func(c *cli.Instance) error { {"mv --force default:x work:..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:x", "work:..", true) return c.MoveSecret(cmd, "default:x", "work:..", true)
}}, }},
{"import --force ..", "..", func(c *cli.Instance) error { {"import --force ..", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "..", missingFile, true) return c.ImportSecret(cmd, "..", missingFile, true)
}}, }},
{"import --force .", ".", func(c *cli.Instance) error { {"import --force .", func(c *cli.Instance) error {
return c.ImportSecret(cmd, ".", missingFile, true) return c.ImportSecret(cmd, ".", missingFile, true)
}}, }},
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error { {"import --force ../../etc", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "../../etc", missingFile, true) return c.ImportSecret(cmd, "../../etc", missingFile, true)
}}, }},
{"version list ..", "..", func(c *cli.Instance) error { {"version list ..", func(c *cli.Instance) error {
return c.ListVersions(cmd, "..") return c.ListVersions(cmd, "..")
}}, }},
{"version promote ..", "..", func(c *cli.Instance) error { {"version promote ..", func(c *cli.Instance) error {
return c.PromoteVersion(cmd, "..", testVersion) return c.PromoteVersion(cmd, "..", testVersion)
}}, }},
{"version rm --force ..", "..", func(c *cli.Instance) error { {"version rm --force ..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion, true) return c.RemoveVersion(cmd, "..", testVersion, true)
}}, }},
{"encrypt ..", "..", func(c *cli.Instance) error { {"encrypt ..", func(c *cli.Instance) error {
return c.Encrypt("..", "", "") return c.Encrypt("..", "", "")
}}, }},
{"decrypt ..", "..", func(c *cli.Instance) error { {"decrypt ..", func(c *cli.Instance) error {
return c.Decrypt("..", "", "") return c.Decrypt("..", "", "")
}}, }},
} }
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, vault.ErrInvalidSecretName, tt.run)
}) })
} }
} }
@@ -299,9 +297,7 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
for _, tt := range commands { for _, tt := range commands {
for _, version := range []string{"", ".", "..", "../../..", "a/b"} { for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) { t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
want := fmt.Errorf("version '%s' %w '%s'", requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound,
version, vault.ErrVersionNotFound, "x")
requireRejectedAndUnchanged(t, before, want,
func(c *cli.Instance) error { return tt.run(c, version) }) func(c *cli.Instance) error { return tt.run(c, version) })
}) })
} }
@@ -355,7 +351,7 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
for _, tt := range commands { for _, tt := range commands {
for _, name := range []string{"", ".", "..", "a/b"} { for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) { t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name), requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
func(c *cli.Instance) error { func(c *cli.Instance) error {
c.Mnemonic = mnemonic c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase c.UnlockPassphrase = passphrase
+1 -1
View File
@@ -3,11 +3,11 @@ package cli
import ( import (
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"golang.org/x/term" "golang.org/x/term"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry runs the secret CLI and returns the process exit code. It wipes // Entry runs the secret CLI and returns the process exit code. It wipes
+8 -17
View File
@@ -11,11 +11,11 @@ import (
"slices" "slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -33,12 +33,6 @@ const (
// Sentinel errors for secret operations // Sentinel errors for secret operations
var ( var (
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit") errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
errSecretFileTooLarge = errors.New(
"secret file too large: exceeds 100MB limit")
errSecretNotFound = errors.New("not found")
errSecretExistsNoForce = errors.New(
"already exists (use --force to overwrite)")
errVaultDoesNotExist = errors.New("does not exist")
errCrossVaultSourceUnqualified = errors.New( 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") errMoveOntoItself = errors.New("cannot be moved onto itself")
@@ -673,10 +667,6 @@ func (cli *Instance) ImportSecret(
buffers, totalSize, err := readSecretFromReader(file) buffers, totalSize, err := readSecretFromReader(file)
if err != nil { if err != nil {
if errors.Is(err, errSecretTooLarge) {
return errSecretFileTooLarge
}
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
} }
defer destroyBuffers(buffers) defer destroyBuffers(buffers)
@@ -776,7 +766,7 @@ func (cli *Instance) findSecretToRemove(
if !exists { if !exists {
return secretToRemove{}, return secretToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
} }
// A secret without a versions directory has no versions, and can // A secret without a versions directory has no versions, and can
@@ -907,7 +897,7 @@ func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
} }
if !slices.Contains(vaults, name) { if !slices.Contains(vaults, name) {
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist) return nil, fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
} }
return vault.NewVault(cli.fs, cli.stateDir, name), nil return vault.NewVault(cli.fs, cli.stateDir, name), nil
@@ -938,7 +928,7 @@ func (cli *Instance) moveSecretWithinVault(
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound) return fmt.Errorf("secret '%s' %w", source, vault.ErrSecretNotFound)
} }
destEncoded := strings.ReplaceAll(dest, "/", "%") destEncoded := strings.ReplaceAll(dest, "/", "%")
@@ -963,7 +953,8 @@ func (cli *Instance) moveSecretWithinVault(
if exists { if exists {
if !force { if !force {
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce) return fmt.Errorf("secret '%s' %w (use --force to overwrite)",
dest, vault.ErrSecretExists)
} }
err = secret.RemoveDirAtomic(cli.fs, destDir) err = secret.RemoveDirAtomic(cli.fs, destDir)
@@ -1028,7 +1019,7 @@ func (cli *Instance) moveSecretCrossVault(
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, vault.ErrSecretNotFound, srcVault.Name)
} }
// The source is removed after the copy, so a destination that is the // The source is removed after the copy, so a destination that is the
+11 -126
View File
@@ -10,57 +10,17 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/unix" "sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testVaultName is the vault name used by the size tests. // testVaultName is the vault name used by the size tests.
const testVaultName = "test-vault" const testVaultName = "test-vault"
// lockedBytesPerSecretByte bounds the locked memory that storing a secret
// holds at once: the buffers it is read into reach up to 1.5 times its
// size, and they are then copied into one more buffer of its size.
const lockedBytesPerSecretByte = 3
// skipIfLockedMemoryTooLow skips the test when this process cannot lock
// the memory a secret of size bytes needs, found by locking a buffer of
// that size and releasing it. memguard panics, ending the whole test run,
// when it cannot lock a buffer, and a plain `docker build .` runs the
// tests under an 8 MiB locked-memory limit (RLIMIT_MEMLOCK). A process
// allowed to lock past that limit runs every case.
func skipIfLockedMemoryTooLow(t *testing.T, size int) {
t.Helper()
need := lockedBytesPerSecretByte * size
buf, err := unix.Mmap(-1, 0, need,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)
require.NoError(t, err)
lockErr := unix.Mlock(buf)
// Unmapping the buffer also unlocks it.
err = unix.Munmap(buf)
require.NoError(t, err)
if lockErr != nil {
var limit unix.Rlimit
err = unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
require.NoError(t, err)
t.Skipf("a %d-byte secret needs up to %d bytes of locked memory, "+
"which could not be locked under the locked-memory limit "+
"(RLIMIT_MEMLOCK) of %d bytes: %v",
size, need, limit.Cur, lockErr)
}
}
// newSizeTestVault creates an in-memory vault unlocked with the test // newSizeTestVault creates an in-memory vault unlocked with the test
// mnemonic and returns the filesystem and vault. // mnemonic and returns the filesystem and vault.
// //
@@ -93,10 +53,9 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
} }
// runAddSecretSizeCase adds a secret of the given size through stdin and // runAddSecretSizeCase adds a secret of the given size through stdin and
// verifies the outcome. // verifies that it is stored.
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) { func runAddSecretSizeCase(t *testing.T, size int) {
t.Helper() t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t) fs, vlt := newSizeTestVault(t)
@@ -127,14 +86,6 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
// Test adding the secret // Test adding the secret
secretName := fmt.Sprintf("test-secret-%d", size) secretName := fmt.Sprintf("test-secret-%d", size)
err = cli.AddSecret(secretName, false) err = cli.AddSecret(secretName, false)
if wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), errMsg)
return
}
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
@@ -148,10 +99,9 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
} }
// runImportSecretSizeCase imports a secret file of the given size and // runImportSecretSizeCase imports a secret file of the given size and
// verifies the outcome. // verifies that it is stored.
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) { func runImportSecretSizeCase(t *testing.T, size int) {
t.Helper() t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t) fs, vlt := newSizeTestVault(t)
@@ -180,14 +130,6 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// Test importing the secret // Test importing the secret
secretName := fmt.Sprintf("imported-secret-%d", size) secretName := fmt.Sprintf("imported-secret-%d", size)
err = cli.ImportSecret(cmd, secretName, testFile, false) err = cli.ImportSecret(cmd, secretName, testFile, false)
if wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), errMsg)
return
}
require.NoError(t, err) require.NoError(t, err)
// Verify the secret was stored correctly // Verify the secret was stored correctly
@@ -202,127 +144,73 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// 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 // in parallel, size tests could exceed the memlock limit
func TestAddSecretVariousSizes(t *testing.T) { func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
size int size int
shouldError bool
errorMsg string
}{ }{
{ {
name: "1KB secret", name: "1KB secret",
size: 1024, size: 1024,
shouldError: false,
}, },
{ {
name: "10KB secret", name: "10KB secret",
size: 10 * 1024, size: 10 * 1024,
shouldError: false,
}, },
{ {
name: "100KB secret", name: "100KB secret",
size: 100 * 1024, size: 100 * 1024,
shouldError: false,
}, },
{ {
name: "1MB secret", name: "1MB secret",
size: 1024 * 1024, size: 1024 * 1024,
shouldError: false,
},
{
name: "10MB secret",
size: 10 * 1024 * 1024,
shouldError: false,
},
{
name: "99MB secret",
size: 99 * 1024 * 1024,
shouldError: false,
},
{
name: "100MB secret minus 1 byte",
size: 100*1024*1024 - 1,
shouldError: false,
},
{
name: "101MB secret - should fail",
size: 101 * 1024 * 1024,
shouldError: true,
errorMsg: "secret too large: exceeds 100MB limit",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg) runAddSecretSizeCase(t, tt.size)
}) })
} }
} }
// 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 // in parallel, size tests could exceed the memlock limit
func TestImportSecretVariousSizes(t *testing.T) { func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
size int size int
shouldError bool
errorMsg string
}{ }{
{ {
name: "1KB file", name: "1KB file",
size: 1024, size: 1024,
shouldError: false,
}, },
{ {
name: "10KB file", name: "10KB file",
size: 10 * 1024, size: 10 * 1024,
shouldError: false,
}, },
{ {
name: "100KB file", name: "100KB file",
size: 100 * 1024, size: 100 * 1024,
shouldError: false,
}, },
{ {
name: "1MB file", name: "1MB file",
size: 1024 * 1024, size: 1024 * 1024,
shouldError: false,
},
{
name: "10MB file",
size: 10 * 1024 * 1024,
shouldError: false,
},
{
name: "99MB file",
size: 99 * 1024 * 1024,
shouldError: false,
},
{
name: "100MB file",
size: 100 * 1024 * 1024,
shouldError: false,
},
{
name: "101MB file - should fail",
size: 101 * 1024 * 1024,
shouldError: true,
errorMsg: "secret file too large: exceeds 100MB limit",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg) runImportSecretSizeCase(t, tt.size)
}) })
} }
} }
// 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 // in parallel, size tests could exceed the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) { func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth // Test various sizes that should trigger buffer growth
sizes := []int{ sizes := []int{
@@ -341,13 +229,10 @@ func TestAddSecretBufferGrowth(t *testing.T) {
131072, // 128KB 131072, // 128KB
524288, // 512KB 524288, // 512KB
1048576, // 1MB 1048576, // 1MB
2097152, // 2MB
} }
for _, size := range sizes { for _, size := range sizes {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) { t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t) fs, vlt := newSizeTestVault(t)
// Create test data of exactly the specified size // Create test data of exactly the specified size
+17 -5
View File
@@ -7,19 +7,19 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret // TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
// value to stdout, not stderr // value to stdout, not stderr
func TestGetCommandOutputsToStdout(t *testing.T) { func TestGetCommandOutputsToStdout(t *testing.T) {
// Create a temporary directory for our vault t.Parallel()
tempDir := t.TempDir()
// Set environment variables for the test // Create a temporary directory for our vault; each command is given it
t.Setenv(secret.EnvStateDir, tempDir) // in its environment
tempDir := t.TempDir()
// Find the secret binary path // Find the secret binary path
wd, err := filepath.Abs("../..") wd, err := filepath.Abs("../..")
@@ -41,6 +41,18 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", string(output)) require.NoError(t, err, "init should succeed: %s", string(output))
// The binary, unlike these tests, encrypts the passphrase unlocker's key
// at age's scrypt work factor, 18. age writes the work factor last on the
// second line of priv.age: "-> scrypt <salt> <work factor>".
vaultDir := filepath.Join(tempDir, "vaults.d", "default")
unlockerName := readFile(t, filepath.Join(vaultDir, "current-unlocker"))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
privAge := readFile(t, filepath.Join(unlockerDir, "priv.age"))
header := strings.SplitN(string(privAge), "\n", 3)
require.Len(t, header, 3, "priv.age should start with an age header")
assert.Regexp(t, `^-> scrypt \S+ 18$`, header[1],
"the passphrase unlocker should be encrypted at scrypt work factor 18")
// Add a secret // Add a secret
//nolint:gosec // G204: test executes the freshly built secret binary //nolint:gosec // G204: test executes the freshly built secret binary
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret") cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"os" "os"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
) )
// ExecuteCommandInProcess executes a CLI command in-process for testing // ExecuteCommandInProcess executes a CLI command in-process for testing
+1 -1
View File
@@ -3,9 +3,9 @@ package cli_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
) )
//nolint:paralleltest // executes the CLI in-process against shared state //nolint:paralleltest // executes the CLI in-process against shared state
+374
View File
@@ -0,0 +1,374 @@
// Unlock Failure Tests
//
// When a vault cannot be opened through its current unlocker, because a
// file the unlocker needs is missing or the passphrase is wrong, the error
// keeps its cause and ends by saying that the mnemonic still opens that
// vault, but only for a vault that the mnemonic does open, and not when the
// passphrase could not be read at all. When a secret's current file is
// missing, the error says how to make a version current again. Each test
// that pins such advice also follows it.
package cli_test
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// mnemonicAdvice ends the error when the current vault "default", which
// its mnemonic opens, cannot be opened through its current unlocker.
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
"to the mnemonic to give it a new unlocker"
// versionAdvice ends the error when a secret's current file cannot be
// read.
versionAdvice = "; this file only names the current version: " +
"'secret version list' lists the secret's versions, and " +
"'secret version promote' makes one of them current"
// unlockTestVaultDir is the directory of the vault "default" of
// newTwoVaultFs, the current vault, whose secret "x" is "value".
unlockTestVaultDir = testStateDir + "/vaults.d/default"
)
// currentUnlockerDir returns the directory of the current unlocker of the
// vault in vaultDir on fs.
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
t.Helper()
unlockerName, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
}
// newUnlockTestCLI returns the directory of the current unlocker of the
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
// instance on fs that has the unlock passphrase, as from the environment,
// but not the mnemonic.
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
t.Helper()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
}
// discardCmd returns a command whose output is discarded.
func discardCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
return cmd
}
// getSecret returns what `secret get name` prints.
func getSecret(t *testing.T, c *cli.Instance, name string) string {
t.Helper()
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.GetSecret(cmd, name))
return out.String()
}
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
// file that opening the vault through its current unlocker needs is
// missing: it keeps the cause, which names the file, and ends with the
// advice that the mnemonic still opens the vault. The test then follows
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
// the vault a new unlocker, which opens it.
func TestUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
file string // the file removed
inVaultDir bool // the file is the vault's, not the unlocker's
want string // the message before the cause
}{
{
file: "current-unlocker",
inVaultDir: true,
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get current unlocker: " +
"failed to read current unlocker: ",
},
{
file: "priv.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get unlocker identity: " +
"failed to read unlocker private key: ",
},
{
file: "longterm.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to read encrypted long-term private key: ",
},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, tt.file)
if tt.inVaultDir {
path = filepath.Join(unlockTestVaultDir, tt.file)
}
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
assert.Equal(t, "value", getSecret(t, c, "x"))
})
}
}
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
// error and ends with the advice that the mnemonic still opens the vault.
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
t.Parallel()
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x")
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to decrypt unlocker private key: failed to create decryptor: "+
noMatch.Error()+mnemonicAdvice)
}
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
// the vault "work", which is not the current vault, when "work" cannot be
// opened through its current unlocker: the advice names "work" and says to
// select it first, since `secret unlocker add` acts on the current vault.
// The test then follows that advice, and the move succeeds.
func TestMoveUnlockFailureNamesVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
path := filepath.Join(
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
require.NoError(t, fs.Remove(path))
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
"failed to get unlocker identity: failed to read unlocker private key: "+
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
require.NoError(t, c.SelectVault(discardCmd(), "work"))
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
assert.Equal(t, "value", getSecret(t, c, "y"))
}
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
// terminal, so the passphrase cannot be read. The unlocker was not tried,
// and adding one would need a passphrase read the same way, so the error
// is the cause alone, without the advice to use the mnemonic.
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
vlt, err := vault.CreateVault(
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("x", value, false))
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
assert.Equal(t, "Error: failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to read passphrase: stdin is not a terminal (piped input or "+
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
"run interactively\n", string(output))
}
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
// `secret decrypt`, reading the key secret, end with the same advice as
// `secret get` when the vault cannot be opened through its current
// unlocker.
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
command string
run func(c *cli.Instance) error
}{
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, "priv.age")
require.NoError(t, fs.Remove(path))
err := tt.run(c)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get secret value: "+
"failed to unlock vault: failed to get long-term key: "+
"failed to get unlocker identity: "+
"failed to read unlocker private key: "+cause.Error()+
mnemonicAdvice)
})
}
}
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
// get` when the secret's current file is missing: it keeps the cause, which
// names the file, and ends with the advice that says how to make a version
// current again. The test then follows that advice.
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
path := filepath.Join(secretDir, "current")
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get current version: "+
"failed to read current version file: "+cause.Error()+versionAdvice)
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
require.NoError(t, err)
require.Len(t, versions, 1)
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.ListVersions(cmd, "x"))
assert.Contains(t, out.String(), versions[0].Name())
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
// created without a mnemonic, which no mnemonic opens, gets no advice to
// use one: `secret unlocker add passphrase` there fails with the cause
// alone.
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
require.NoError(t, err)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
err = c.UnlockersAdd("passphrase", discardCmd())
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.EqualError(t, err, "failed to get long-term key: "+
"failed to get current unlocker: failed to read current unlocker: "+
cause.Error())
}
+25 -163
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"maps"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -14,10 +15,10 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Unlocker type names and platform identifiers shared across the CLI // Unlocker type names and platform identifiers shared across the CLI
@@ -38,15 +39,10 @@ var (
errInvalidUnlockerType = errors.New("invalid unlocker type") errInvalidUnlockerType = errors.New("invalid unlocker type")
errKeyIDOnlyForPGP = errors.New( errKeyIDOnlyForPGP = errors.New(
"--keyid flag is only valid for PGP unlockers") "--keyid flag is only valid for PGP unlockers")
errKeychainMacOSOnly = errors.New(
"keychain unlockers are only supported on macOS")
errSecureEnclaveMacOSOnly = errors.New(
"secure enclave unlockers are only supported on macOS")
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller // errGPGKeyAlreadyUnlocker carries only the message tail; the caller
// composes "GPG key <id> is already added as an unlocker". // composes "GPG key <id> is already added as an unlocker".
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
"is already added as an unlocker") "is already added as an unlocker")
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
) )
// UnlockerInfo represents unlocker information for display // UnlockerInfo represents unlocker information for display
@@ -313,91 +309,8 @@ func newUnlockerSelectCmd() *cobra.Command {
} }
} }
// unlockerIDFromDir constructs an unlocker of the given metadata type // UnlockersList lists unlockers in the current vault, each under its ID,
// rooted at unlockerDir and returns its ID. Returns "" for unknown types // the name of its directory in unlockers.d
// and, when includeSecureEnclave is false, for secure enclave unlockers.
func unlockerIDFromDir(
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) string {
// Create the appropriate unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case unlockerTypePassphrase:
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
case unlockerTypeKeychain:
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
case unlockerTypePGP:
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
case unlockerTypeSecureEnclave:
if includeSecureEnclave {
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
}
}
if unlocker == nil {
return ""
}
return unlocker.GetID()
}
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
// stored metadata matches the given type and creation time and returns
// the matching unlocker's ID. It returns ("", nil) when the directory is
// readable but holds no match, and a non-nil error when the directory
// itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID.
//
// A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first,
// which has already warned about that directory.
func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) (string, error) {
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
return "", fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type &&
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
includeSecureEnclave), nil
}
}
return "", nil
}
// UnlockersList lists unlockers in the current vault
func (cli *Instance) UnlockersList(jsonOutput bool) error { func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@@ -413,58 +326,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
currentUnlockerID = currentUnlocker.GetID() currentUnlockerID = currentUnlocker.GetID()
} }
// Get the metadata first unlockerMetadata, err := vlt.ListUnlockers()
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return err return err
} }
// Load actual unlocker objects to get the proper IDs
var unlockers []UnlockerInfo var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList { for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
// Create unlocker instance to get the proper ID metadata := unlockerMetadata[unlockerID]
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers",
"error", err)
continue unlockers = append(unlockers, UnlockerInfo{
} ID: unlockerID,
// Find the unlocker directory by type and created time
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockerID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, metadata, true,
)
if err != nil {
secret.Warn("Could not read unlockers directory, skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
// Get the proper ID using the unlocker's ID() method
var properID string
if unlockerID != "" {
properID = unlockerID
} else {
// Generate ID as fallback
properID = fmt.Sprintf("%s-%s",
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
secret.Warn("Could not create unlocker instance, using fallback ID",
"fallback_id", properID, "type", metadata.Type)
}
unlockerInfo := UnlockerInfo{
ID: properID,
Type: metadata.Type, Type: metadata.Type,
CreatedAt: metadata.CreatedAt, CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags, Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID, IsCurrent: unlockerID == currentUnlockerID,
} })
unlockers = append(unlockers, unlockerInfo)
} }
if jsonOutput { if jsonOutput {
@@ -556,7 +434,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
} }
return fmt.Errorf("%w: %s (supported: %s)", return fmt.Errorf("%w: %s (supported: %s)",
errUnsupportedUnlockerType, unlockerType, supportedTypes) errInvalidUnlockerType, unlockerType, supportedTypes)
} }
} }
@@ -591,7 +469,7 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) 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 fmt.Errorf("failed to read passphrase: %w", err) return err
} }
defer passphraseBuffer.Destroy() defer passphraseBuffer.Destroy()
} }
@@ -611,10 +489,6 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault // addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error { func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errKeychainMacOSOnly
}
keychainUnlocker, err := secret.CreateKeychainUnlocker( keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
@@ -642,10 +516,6 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the // addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
// current vault // current vault
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error { func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errSecureEnclaveMacOSOnly
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker( seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
@@ -697,9 +567,7 @@ 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 exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
exists, err := cli.checkUnlockerExists(vlt, expectedID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w", "could not check whether GPG key %s is already an unlocker: %w",
@@ -804,13 +672,7 @@ func (cli *Instance) findUnlockerToRemove(
} }
if len(unlockers) == 1 { if len(unlockers) == 1 {
lastID, err := findUnlockerIDByMetadata( _, found.last = unlockers[unlockerID]
cli.fs, unlockersDir, unlockers[0], true)
if err != nil {
return unlockerToRemove{}, err
}
found.last = lastID == unlockerID
} }
// unlockerID may instead name a directory left out of the list. If its // unlockerID may instead name a directory left out of the list. If its
@@ -889,16 +751,16 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) return vlt.SelectUnlocker(unlockerID)
} }
// checkUnlockerExists reports whether the vault already has an unlocker // pgpUnlockerExists reports whether the vault already has a PGP unlocker
// with the given ID. It returns an error, and no answer, when unlockers.d // for the GPG key with the given fingerprint. It returns an error, and no
// or an unlocker's metadata file cannot be read; the caller must then not // answer, when unlockers.d or an unlocker's metadata file cannot be read;
// create the unlocker. It reads unlockers.d itself because // the caller must then not create the unlocker. It reads unlockers.d itself
// vault.ListUnlockers skips an unlocker it cannot read, which suits // because vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the // `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not // duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over. // a working unlocker and is passed over.
func (cli *Instance) checkUnlockerExists( func (cli *Instance) pgpUnlockerExists(
vlt *vault.Vault, unlockerID string, vlt *vault.Vault, fingerprint string,
) (bool, error) { ) (bool, error) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
@@ -937,14 +799,14 @@ func (cli *Instance) checkUnlockerExists(
) )
} }
var metadata secret.UnlockerMetadata var metadata secret.PGPUnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata) err = json.Unmarshal(metadataBytes, &metadata)
if err != nil { if err != nil {
continue continue
} }
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID { if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
return true, nil return true, nil
} }
} }
+3 -2
View File
@@ -5,11 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has. // unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
@@ -98,7 +99,7 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) {
err := instance.addPGPUnlocker(cmd) err := instance.addPGPUnlocker(cmd)
require.ErrorContains(t, err, "failed to resolve GPG key fingerprint") require.ErrorIs(t, err, secret.ErrGPGKeyNotFound)
assertDirEntries(t, base, assertDirEntries(t, base,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName), filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
listTestUnlockerDirOne) listTestUnlockerDirOne)
+3 -3
View File
@@ -17,10 +17,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// newCorruptUnlockerVault returns the two-unlocker test vault with the // newCorruptUnlockerVault returns the two-unlocker test vault with the
@@ -46,7 +46,7 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
fs := newCorruptUnlockerVault(t) fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs) instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B")) require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
current, err := afero.ReadFile(fs, current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker")) filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
@@ -72,7 +72,7 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
}{ }{
{ {
name: "the other unlocker", name: "the other unlocker",
unlockerID: "pgp-" + listTestGPGKeyID + "B", unlockerID: listTestUnlockerDirTwo,
wantLast: true, wantLast: true,
wantEntries: []string{listTestUnlockerDirOne}, wantEntries: []string{listTestUnlockerDirOne},
}, },
+79
View File
@@ -0,0 +1,79 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
// side by side whose metadata is the same, creation time included, as
// copying an unlocker directory leaves them. It asserts that `unlocker
// list` and the shell completion of `unlocker select` and `unlocker remove`
// give each its own ID, and that each is selected and removed by its ID
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
// get their IDs the same way.
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
dirNames := []string{
"passphrase-2026-10-04.12.30.00.000000000",
"passphrase-2026-10-04.12.30.00.000000000-copy",
}
metadata, err := json.Marshal(secret.UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
})
require.NoError(t, err)
for _, dirName := range dirNames {
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dir, listTestMetadataFileName), metadata,
listTestFilePerm))
}
listed := listUnlockersJSON(t, fs)
require.Len(t, listed, len(dirNames))
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
nil, nil, "")
assert.Equal(t, dirNames, completed)
instance, cmd := newTestInstance(fs)
for i, unlocker := range listed {
assert.Equal(t, dirNames[i], unlocker.ID)
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
current, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, dirNames[i], string(current))
}
// The second one first: an ID both shared would remove the first one
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir, dirNames[0])
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir)
}
+25 -80
View File
@@ -1,25 +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 an unlocker's metadata
// or an unlocker's metadata in it, cannot be read while the listing is // cannot be read or used:
// being rendered:
// //
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails.
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt // - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed. // metadata does not stop the others from being listed.
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata // - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
// file cannot be checked for or read is left out, and the other is // file cannot be checked for or read is left out, and the other is
// still listed. // still listed.
//
// The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID
// is unknowable, so the entry must be skipped: a synthesized ID matches
// no `unlocker remove` or `unlocker select` argument and would also
// suppress the current-unlocker marker.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
@@ -33,11 +21,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -48,18 +36,16 @@ const (
// listTestVaultName is the name of that synthetic vault. // listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default" listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP // listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
// unlocker's metadata. The unlocker's real ID is derived from it, and // in the PGP unlockers' metadata.
// differs from the timestamp-derived fallback ID.
listTestGPGKeyID = "DEADBEEFDEADBEEF" listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker // listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d. // directory names under unlockers.d, and so the unlockers' IDs.
listTestUnlockerDirOne = "host-pgp-2026-08-09" listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10" listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory the listing rescans to // listTestUnlockersDirName is the directory holding the unlockers.
// resolve unlocker IDs.
listTestUnlockersDirName = "unlockers.d" listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name. // listTestMetadataFileName is the per-unlocker metadata file name.
@@ -74,26 +60,17 @@ const (
// a successful open of unlockers.d. // a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied") var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened // unlockersDirFailFs fails every open of unlockers.d, as when the
// successfully openBudget times. This reproduces the directory becoming // directory cannot be read.
// unreadable (permission change, partially restored backup, EIO) between
// the vault's own enumeration and the per-entry rescan that resolves
// unlocker IDs.
type unlockersDirFailFs struct { type unlockersDirFailFs struct {
afero.Fs afero.Fs
openBudget int
opens int
} }
//nolint:ireturn // afero.File is the interface required by afero.Fs //nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) { func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName { if filepath.Base(name) == listTestUnlockersDirName {
f.opens++
if f.opens > f.openBudget {
return nil, errUnlockersDirUnreadable return nil, errUnlockersDirUnreadable
} }
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is //nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name) return f.Fs.Open(name)
@@ -142,8 +119,8 @@ func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
return f.Fs.Stat(name) return f.Fs.Stat(name)
} }
// writePGPUnlocker writes a PGP unlocker directory with metadata that // writePGPUnlocker writes a PGP unlocker directory named dirName, with
// yields the real ID "pgp-<keyID>". // metadata recording the GPG key ID keyID.
func writePGPUnlocker( func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string, t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string, createdAt time.Time, keyID string,
@@ -224,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
return decoded.Unlockers return decoded.Unlockers
} }
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
// which becomes unreadable after the vault enumerated it produces no rows,
// rather than rows carrying fabricated fallback IDs.
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
// Budget of one: the vault's own ListUnlockers scan succeeds, the
// per-entry rescan that resolves the ID fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
unlockers := listUnlockersJSON(t, fs)
assert.Empty(t, unlockers,
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
}
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
// entry survives with its real ID and current-unlocker marker when a later
// entry's rescan fails.
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
// the second entry's rescan fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the entry whose directory was readable may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
"the surviving row must carry the real unlocker ID")
assert.True(t, unlockers[0].IsCurrent,
"the current-unlocker marker must survive the skip")
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a // TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID. // fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) { func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
@@ -272,20 +211,21 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
unlockers := listUnlockersJSON(t, base) unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2) require.Len(t, unlockers, 2)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID) assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID) assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
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 // TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON // 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 // leaves that unlocker out; PGP metadata without a usable GPG key ID, and
// it as "pgp-unknown". The healthy unlocker is listed with its real ID. // metadata of an unknown type, are still listed, under the directory name
// like any other. The healthy unlocker is listed with its real ID.
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) { func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
t.Parallel() t.Parallel()
healthyID := "pgp-" + listTestGPGKeyID + "A" healthyID := listTestUnlockerDirOne
tests := []struct { tests := []struct {
name string name string
@@ -300,12 +240,17 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
{ {
name: "GPG key ID of the wrong type", name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`, metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, "pgp-unknown"}, wantIDs: []string{healthyID, listTestUnlockerDirTwo},
}, },
{ {
name: "GPG key ID missing", name: "GPG key ID missing",
metadata: `{"type": "pgp"}`, metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, "pgp-unknown"}, wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
}, },
} }
@@ -371,7 +316,7 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
require.Len(t, unlockers, 1, require.Len(t, unlockers, 1,
"only the unlocker with usable metadata may be listed") "only the unlocker with usable metadata may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID, assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
"the listed row must carry the real unlocker ID") "the listed row must carry the real unlocker ID")
}) })
} }
+2 -2
View File
@@ -28,11 +28,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -290,7 +290,7 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path}) instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A") _, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.ErrorIs(t, err, errStatFailed) require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne) assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// usageHeading starts the usage text cobra prints after an error. // usageHeading starts the usage text cobra prints after an error.
+7 -8
View File
@@ -10,20 +10,19 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Sentinel errors for vault operations // Sentinel errors for vault operations
var ( var (
errMnemonicEmpty = errors.New("mnemonic cannot be empty") errMnemonicEmpty = errors.New("mnemonic cannot be empty")
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase") errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
errVaultHasLongTermKey = errors.New( errVaultHasLongTermKey = errors.New(
"already has a long-term key configured") "already has a long-term key configured")
errMnemonicEnvNotSet = errors.New( errMnemonicEnvNotSet = errors.New(
@@ -250,7 +249,7 @@ 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, nil, err
} }
return passphraseBuffer, passphraseBuffer.Destroy, nil return passphraseBuffer, passphraseBuffer.Destroy, nil
@@ -353,7 +352,7 @@ func (cli *Instance) vaultImportPreflight(
if !exists { if !exists {
return "", "", "", fmt.Errorf("vault '%s' %w", return "", "", "", fmt.Errorf("vault '%s' %w",
vaultName, errVaultDoesNotExist) vaultName, vault.ErrVaultNotFound)
} }
// Check if vault already has a public key // Check if vault already has a public key
@@ -381,7 +380,7 @@ func (cli *Instance) vaultImportPreflight(
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonic) { if !bip39.IsMnemonicValid(mnemonic) {
return "", "", "", errInvalidMnemonic return "", "", "", errInvalidMnemonicPhrase
} }
return vaultDir, pubKeyPath, mnemonic, nil return vaultDir, pubKeyPath, mnemonic, nil
@@ -644,7 +643,7 @@ func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
if !slices.Contains(vaults, name) { if !slices.Contains(vaults, name) {
return vaultToRemove{}, return vaultToRemove{},
fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist) fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
} }
if len(vaults) == 1 { if len(vaults) == 1 {
+6 -7
View File
@@ -11,10 +11,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -23,7 +23,6 @@ const (
// Sentinel errors for version operations // Sentinel errors for version operations
var ( var (
errVersionNotFound = errors.New("not found for secret")
errCannotRemoveCurrentVersion = errors.New("promote another version first") errCannotRemoveCurrentVersion = errors.New("promote another version first")
) )
@@ -156,7 +155,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
if !exists { if !exists {
secret.Debug("Secret not found", "secret_name", secretName) secret.Debug("Secret not found", "secret_name", secretName)
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
} }
// List all versions // List all versions
@@ -289,7 +288,7 @@ func (cli *Instance) PromoteVersion(
if !exists { if !exists {
return fmt.Errorf("version '%s' %w '%s'", return fmt.Errorf("version '%s' %w '%s'",
version, errVersionNotFound, secretName) version, vault.ErrVersionNotFound, secretName)
} }
// Update the current symlink using the proper function // Update the current symlink using the proper function
@@ -374,7 +373,7 @@ func (cli *Instance) findVersionToRemove(
if !exists { if !exists {
return versionToRemove{}, return versionToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
} }
// Check if version exists // Check if version exists
@@ -386,7 +385,7 @@ func (cli *Instance) findVersionToRemove(
if !exists { if !exists {
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'", return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
version, errVersionNotFound, secretName) version, vault.ErrVersionNotFound, secretName)
} }
// Get current version // Get current version
+5 -7
View File
@@ -26,13 +26,13 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -171,8 +171,7 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
// Try to list versions of non-existent secret // Try to list versions of non-existent secret
err := cli.ListVersions(cmd, "nonexistent/secret") err := cli.ListVersions(cmd, "nonexistent/secret")
require.Error(t, err) require.ErrorIs(t, err, vault.ErrSecretNotFound)
assert.Contains(t, err.Error(), "not found")
} }
func TestPromoteVersionCommand(t *testing.T) { func TestPromoteVersionCommand(t *testing.T) {
@@ -266,8 +265,7 @@ func TestPromoteNonExistentVersion(t *testing.T) {
// Try to promote non-existent version // Try to promote non-existent version
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999") err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
require.Error(t, err) require.ErrorIs(t, err, vault.ErrVersionNotFound)
assert.Contains(t, err.Error(), "not found")
} }
func TestGetSecretWithVersion(t *testing.T) { func TestGetSecretWithVersion(t *testing.T) {
+24 -5
View File
@@ -15,6 +15,7 @@ package macse
import "C" import "C"
import ( import (
"errors"
"fmt" "fmt"
"unsafe" "unsafe"
) )
@@ -39,10 +40,9 @@ const (
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth. // CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
// Returns the uncompressed public key bytes (65 bytes) and the identity hash // Returns the uncompressed public key bytes (65 bytes) and the identity hash
// (for deletion). // (for deletion). If getting the public key fails, CreateKey deletes the key
// again; a failure to delete is returned along with the first error.
func CreateKey(label string) (publicKey []byte, hash string, err error) { func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
var hashBuf [hashBufferSize]C.char var hashBuf [hashBufferSize]C.char
var errBuf [errorBufferSize]C.char var errBuf [errorBufferSize]C.char
@@ -50,7 +50,6 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
result := C.se_create_key(cLabel, result := C.se_create_key(cLabel,
&pubKeyBuf[0], &pubKeyLen,
&hashBuf[0], C.int(hashBufferSize), &hashBuf[0], C.int(hashBufferSize),
&errBuf[0], C.int(errorBufferSize)) &errBuf[0], C.int(errorBufferSize))
@@ -58,9 +57,29 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
h := C.GoString(&hashBuf[0])
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
result = C.se_copy_public_key(cLabel,
&pubKeyBuf[0], &pubKeyLen,
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
deleteErr := DeleteKey(h)
if deleteErr != nil {
err = errors.Join(err,
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
}
return nil, "", err
}
//nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
h := C.GoString(&hashBuf[0])
return pk, h, nil return pk, h, nil
} }
+14 -4
View File
@@ -5,20 +5,30 @@
#include <stdint.h> #include <stdint.h>
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth. // se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
// finds its identity hash. If the hash cannot be found, the key exists but
// se_create_key fails, with an error naming the label.
// label: unique identifier for the CTK identity (UTF-8 C string) // label: unique identifier for the CTK identity (UTF-8 C string)
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// hash_out: output buffer for the identity hash (for deletion) // hash_out: output buffer for the identity hash (for deletion)
// hash_out_len: size of hash_out buffer // hash_out_len: size of hash_out buffer
// error_out: output buffer for error message // error_out: output buffer for error message
// error_out_len: size of error_out buffer // error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure. // Returns 0 on success, -1 on failure.
int se_create_key(const char *label, int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len, char *hash_out, int hash_out_len,
char *error_out, int error_out_len); char *error_out, int error_out_len);
// se_copy_public_key copies the public key of a CTK identity.
// label: label of the CTK identity
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_copy_public_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *error_out, int error_out_len);
// se_encrypt encrypts data using the SE-backed public key (ECIES). // se_encrypt encrypts data using the SE-backed public key (ECIES).
// label: label of the CTK identity whose public key to use // label: label of the CTK identity whose public key to use
// plaintext: data to encrypt // plaintext: data to encrypt
+50 -35
View File
@@ -47,7 +47,6 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
} }
int se_create_key(const char *label, int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len, char *hash_out, int hash_out_len,
char *error_out, int error_out_len) { char *error_out, int error_out_len) {
@autoreleasepool { @autoreleasepool {
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
return -1; return -1;
} }
// Retrieve the public key from the created identity // Get the identity hash, which deleting the key needs, by parsing
// sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
if (hash_out[0] == '\0') {
NSString *msg = [NSString stringWithFormat:
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
label];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
return 0;
}
}
int se_copy_public_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *error_out, int error_out_len) {
@autoreleasepool {
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len); SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
if (!privateKey) { if (!privateKey) {
return -1; return -1;
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
*pub_key_len = (int)length; *pub_key_len = (int)length;
CFRelease(pubKeyData); CFRelease(pubKeyData);
// Get the identity hash by parsing sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
return 0; return 0;
} }
} }
+42 -2
View File
@@ -8,12 +8,13 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/macse"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
var errInjected = errors.New("injected failure") var errInjected = errors.New("injected failure")
@@ -899,3 +900,42 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
}) })
} }
} }
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
// Enclave unlocker into place fail after its Secure Enclave key is created:
// the key must be deleted again. Skipped when the add fails before that, as
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
// Enclave.
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
t.Parallel()
mnemonic := testMnemonicBuffer(t)
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err)
// The unlocker's directory is named se-<label of its Secure Enclave key>
var seKeyLabel string
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
return errInjected
}
return nil
}}
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
nil)
if seKeyLabel == "" {
t.Skipf("the add failed before moving the unlocker into place: %v", err)
}
require.ErrorIs(t, err, errInjected)
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
assert.Error(t, err, "Secure Enclave key left behind")
}
+54 -19
View File
@@ -17,16 +17,28 @@ import (
var ( var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil") errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errStdinNotTerminal = errors.New( errStdinNotTerminal = errors.New(
"cannot read passphrase from non-terminal stdin " + "stdin is not a terminal (piped input or script)")
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable or run interactively")
errStderrNotTerminal = errors.New( errStderrNotTerminal = errors.New(
"cannot prompt for passphrase: stderr is not a terminal " + "stderr is not a terminal (running in non-interactive mode)")
"(running in non-interactive mode). Please set the " + errNothingEntered = errors.New("nothing was entered")
"SB_UNLOCK_PASSPHRASE environment variable")
errEmptyPassphrase = errors.New("passphrase cannot be empty") errEmptyPassphrase = errors.New("passphrase cannot be empty")
) )
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
// terminal to read the mnemonic from, reading it failed, or it was empty.
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
// ScryptWorkFactor is, when not zero, the scrypt work factor that
// EncryptWithPassphrase uses instead of age's, 18: log2 of scrypt's cost
// parameter N. Deriving a key with age's takes about a second and 256 MiB, on
// purpose, since so does every guess at the passphrase. Only tests set it,
// lower, before any test runs, so that the passphrase unlockers they create
// cost nothing; the program leaves it zero. Decryption takes the work factor
// from the encrypted data, so it needs no setting.
//
//nolint:gochecknoglobals // set by the tests of the packages that use this one
var ScryptWorkFactor int
// EncryptToRecipient encrypts data to a recipient using age // EncryptToRecipient encrypts data to a recipient using age
// The data parameter should be a LockedBuffer for secure memory handling // The data parameter should be a LockedBuffer for secure memory handling
func EncryptToRecipient( func EncryptToRecipient(
@@ -141,6 +153,10 @@ func EncryptWithPassphrase(
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err) return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
} }
if ScryptWorkFactor != 0 {
recipient.SetWorkFactor(ScryptWorkFactor)
}
return EncryptToRecipient(data, recipient) return EncryptToRecipient(data, recipient)
} }
@@ -166,42 +182,61 @@ func DecryptWithPassphrase(
// ReadPassphrase reads a passphrase securely from the terminal without echoing // ReadPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // This version is for unlocking and doesn't require confirmation
// Returns a LockedBuffer containing the passphrase for secure memory handling // Returns a LockedBuffer containing the passphrase for secure memory handling.
// Every error it returns wraps ErrPassphraseNotRead.
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) { func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
}
// ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a
// passphrase. Every error it returns wraps ErrMnemonicNotRead.
func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
}
// readFromTerminal reads input from the terminal without echoing it. Every
// error it returns wraps notRead; without a terminal, the error says to set
// envVar instead.
func readFromTerminal(
prompt string, notRead error, envVar string,
) (*memguard.LockedBuffer, error) {
// Check if stdin is a terminal // Check if stdin is a terminal
if !term.IsTerminal(syscall.Stdin) { if !term.IsTerminal(syscall.Stdin) {
// Not a terminal - never read passphrases from piped input // Not a terminal - never read secrets from piped input
// for security reasons // for security reasons
return nil, errStdinNotTerminal return nil, fmt.Errorf(
"%w: %w. Please set the %s environment variable or run interactively",
notRead, errStdinNotTerminal, envVar)
} }
// stdin is a terminal, check if stderr is also a terminal for // stdin is a terminal, check if stderr is also a terminal for
// interactive prompting // interactive prompting
if !term.IsTerminal(syscall.Stderr) { if !term.IsTerminal(syscall.Stderr) {
return nil, errStderrNotTerminal return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
notRead, errStderrNotTerminal, envVar)
} }
// Both stdin and stderr are terminals - use secure password reading // Both stdin and stderr are terminals - use secure password reading
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
passphrase, err := term.ReadPassword(syscall.Stdin) input, err := term.ReadPassword(syscall.Stdin)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, fmt.Errorf("%w: %w", notRead, err)
} }
// Print newline to stderr since ReadPassword doesn't echo // Print newline to stderr since ReadPassword doesn't echo
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
if len(passphrase) == 0 { if len(input) == 0 {
return nil, errEmptyPassphrase return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
} }
// Create a secure buffer and copy the passphrase // Create a secure buffer and copy the input
secureBuffer := memguard.NewBufferFromBytes(passphrase) secureBuffer := memguard.NewBufferFromBytes(input)
// Clear the original passphrase slice // Clear the original input slice
for i := range passphrase { for i := range input {
passphrase[i] = 0 input[i] = 0
} }
return secureBuffer, nil return secureBuffer, nil
+1 -1
View File
@@ -4,9 +4,9 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
// TestIdentityToLockedBuffer checks that the buffer holds the identity's // TestIdentityToLockedBuffer checks that the buffer holds the identity's
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/pkg/agehd"
) )
// realVault is a minimal VaultInterface backed by a real afero filesystem, // realVault is a minimal VaultInterface backed by a real afero filesystem,
+1 -1
View File
@@ -3,7 +3,7 @@ package secret_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
) )
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) { func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
+24 -39
View File
@@ -11,13 +11,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -39,8 +38,6 @@ const (
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var ( var (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty") errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format") errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New( errUnsupportedCurrentUnlocker = errors.New(
@@ -156,20 +153,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID implements Unlocker interface - generates ID from keychain item name // GetID implements Unlocker interface: the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain return filepath.Base(k.Directory)
// This matches the passphrase unlocker format
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Use the creation timestamp from metadata
createdAt := k.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
} }
// Remove implements Unlocker interface - removes the keychain unlocker // Remove implements Unlocker interface - removes the keychain unlocker
@@ -405,12 +391,6 @@ func deriveLongTermPrivateKey(
func CreateKeychainUnlocker( func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) { ) (*KeychainUnlocker, error) {
// Check if we're on macOS
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
// Get current vault using the GetCurrentVault function from the same package // Get current vault using the GetCurrentVault function from the same package
vault, err := GetCurrentVault(fs, stateDir) vault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
@@ -490,6 +470,8 @@ func CreateKeychainUnlocker(
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and // writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker). // stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
// The data is stored after the unlocker's files are written, and the keychain
// item is deleted again if moving the unlocker into place then fails.
func writeKeychainUnlocker( func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string, fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte, encryptedAgePrivKey, encryptedLtPrivKey []byte,
@@ -510,8 +492,10 @@ func writeKeychainUnlocker(
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
// Step 8: Write the unlocker's files and store the data in the keychain, // Step 8: Write the unlocker's files, the metadata last, then store the
// the metadata last // data in the keychain
stored := false
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient)) err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil { if err != nil {
@@ -528,19 +512,29 @@ func writeKeychainUnlocker(
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"), err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes) metadataBytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err) return fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
stored = true
return nil return nil
}) })
if err != nil && stored {
deleteErr := deleteFromKeychain(keychainItemName)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
}
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -552,15 +546,6 @@ func writeKeychainUnlocker(
}, nil }, nil
} }
// checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for // validateKeychainItemName validates that a keychain item name is safe for
// command execution // command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain // storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error { func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil { if data == nil {
return fmt.Errorf("data buffer is nil") return errNilDataBuffer
} }
if err := validateKeychainItemName(itemName); err != nil { if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err) return fmt.Errorf("invalid keychain item name: %w", err)
+3 -2
View File
@@ -4,6 +4,7 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -60,9 +61,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID returns the unlocker ID // GetID returns the unlocker ID, the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain" return filepath.Base(k.Directory)
} }
// GetKeychainItemName returns an error on non-Darwin platforms // GetKeychainItemName returns an error on non-Darwin platforms
+34 -6
View File
@@ -4,10 +4,13 @@ package secret
import ( import (
"encoding/hex" "encoding/hex"
"os"
"path/filepath"
"runtime" "runtime"
"testing" "testing"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -69,9 +72,12 @@ func TestKeychainInvalidItemName(t *testing.T) {
testData := memguard.NewBufferFromBytes([]byte("test")) testData := memguard.NewBufferFromBytes([]byte("test"))
defer testData.Destroy() defer testData.Destroy()
// Test an empty item name
err := storeInKeychain("", testData)
require.ErrorIs(t, err, errKeychainItemNameEmpty)
// Test invalid item names // Test invalid item names
invalidNames := []string{ invalidNames := []string{
"", // Empty name
"test space", // Contains space "test space", // Contains space
"test/slash", // Contains slash "test/slash", // Contains slash
"test\\backslash", // Contains backslash "test\\backslash", // Contains backslash
@@ -93,9 +99,8 @@ func TestKeychainInvalidItemName(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
err := storeInKeychain(name, testData) err := storeInKeychain(name, testData)
assert.Error(t, err, "Expected error for invalid name: %s", name) require.ErrorIs(t, err, errInvalidKeychainItemName,
assert.Contains(t, err.Error(), "invalid keychain item name", "Expected error for invalid name: %s", name)
"Error should mention invalid name for: %s", name)
} }
// Test valid names (should not error on validation) // Test valid names (should not error on validation)
@@ -125,8 +130,7 @@ func TestKeychainNilData(t *testing.T) {
// Test storing nil data // Test storing nil data
err := storeInKeychain("test-item", nil) err := storeInKeychain("test-item", nil)
assert.Error(t, err, "Expected error when storing nil data") require.ErrorIs(t, err, errNilDataBuffer)
assert.Contains(t, err.Error(), "data buffer is nil")
} }
func TestKeychainLargeData(t *testing.T) { func TestKeychainLargeData(t *testing.T) {
@@ -185,3 +189,27 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
assert.NoError(t, err, assert.NoError(t, err,
"Deleting non-existent keychain item should not return an error") "Deleting non-existent keychain item should not return an error")
} }
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
// unlocker into place fail after its data is stored in the keychain: the
// keychain item must be deleted again.
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
testItemName := "test-secret-keychain-unlocker-cleanup"
_ = deleteFromKeychain(testItemName)
// Moving the unlocker into a read-only directory fails
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
defer testBuffer.Destroy()
_, err := writeKeychainUnlocker(afero.NewOsFs(),
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
require.ErrorIs(t, err, os.ErrPermission,
"moving the unlocker into place should fail")
_, err = retrieveFromKeychain(testItemName)
assert.Error(t, err, "keychain item left behind")
}
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the standard BIP39 test vector mnemonic. // testMnemonic is the standard BIP39 test vector mnemonic.
+9 -6
View File
@@ -1,6 +1,7 @@
package secret package secret
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -10,6 +11,11 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// ErrPassphraseNotRead is wrapped in every error of ReadPassphrase: there
// is no terminal to read the passphrase from, reading it failed, or it was
// empty. A passphrase unlocker that fails with it was not tried.
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
// PassphraseUnlocker represents a passphrase-protected unlocker // PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct { type PassphraseUnlocker struct {
Directory string Directory string
@@ -109,12 +115,9 @@ func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface - generates ID from creation timestamp // GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PassphraseUnlocker) GetID() string { func (p *PassphraseUnlocker) GetID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase return filepath.Base(p.Directory)
createdAt := p.Metadata.CreatedAt
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
} }
// Remove implements Unlocker interface - removes the passphrase unlocker // Remove implements Unlocker interface - removes the passphrase unlocker
@@ -152,7 +155,7 @@ func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
if err != nil { if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID()) Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, err
} }
return secureBuffer, nil return secureBuffer, nil
+9 -59
View File
@@ -17,11 +17,11 @@ import (
"time" "time"
"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/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// pgpUnlockerType is the type of a PGP unlocker. // pgpUnlockerType is the type of a PGP unlocker.
@@ -297,11 +297,6 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Create a PGP unlocker for the remaining tests // Create a PGP unlocker for the remaining tests
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata) unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) {
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
})
// Test getting identity from PGP unlocker // Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) { t.Run("GetIdentity", func(t *testing.T) {
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID) testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
@@ -322,8 +317,8 @@ func testCreatePGPUnlocker(
t.Helper() t.Helper()
// Set a limited test timeout to avoid hanging // Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() { timer := time.AfterFunc(10*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds") t.Fatalf("Test timed out after 10 seconds")
}) })
defer timer.Stop() defer timer.Stop()
@@ -396,10 +391,10 @@ func testCreatePGPUnlocker(
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType()) t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
} }
// Check if the key ID includes the GPG fingerprint // Check that the ID is the name of the unlocker's directory
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) { if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
pgpUnlocker.GetID(), fingerprint) pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
} }
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory()) checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
@@ -504,51 +499,6 @@ func checkPGPUnlockerMetadata(
} }
} }
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
// into unlockerDir and checks that unlocker reads it back.
func testGetGPGKeyID(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
) {
t.Helper()
// Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key // testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it. // keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity( func testPGPUnlockerGetIdentity(
+18 -40
View File
@@ -18,6 +18,10 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// gpgNoPublicKeyStatus is the status line gpg writes when it has no key for
// the ID it was asked to list: 9 is gpg's error code for "No public key".
const gpgNoPublicKeyStatus = "[GNUPG:] ERROR keylist.getkey 9\n"
var ( var (
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty") errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
errInvalidGPGKeyID = errors.New("invalid GPG key ID format") errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
@@ -25,6 +29,10 @@ var (
errNilDataBuffer = errors.New("data buffer is nil") errNilDataBuffer = errors.New("data buffer is nil")
) )
// ErrGPGKeyNotFound is returned by ResolveGPGKeyFingerprint for a key ID
// that matches no key in the GPG keyring.
var ErrGPGKeyNotFound = errors.New("GPG key not found")
// Variables to allow overriding in tests // Variables to allow overriding in tests
var ( var (
// GPGEncryptFunc is the function used for GPG encryption // GPGEncryptFunc is the function used for GPG encryption
@@ -155,21 +163,9 @@ 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: the name of the unlocker's directory
// 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> return filepath.Base(p.Directory)
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
"directory", p.Directory, "error", err)
return "pgp-unknown"
}
return "pgp-" + gpgKeyID
} }
// Remove implements Unlocker interface - removes the PGP unlocker // Remove implements Unlocker interface - removes the PGP unlocker
@@ -184,30 +180,6 @@ func (p *PGPUnlocker) Remove() error {
return nil return nil
} }
// GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
// Load the metadata
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
}
var pgpMetadata PGPUnlockerMetadata
err = json.Unmarshal(metadataData, &pgpMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
if pgpMetadata.GPGKeyID == "" {
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
}
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 time
func generatePGPUnlockerName() (string, error) { func generatePGPUnlockerName() (string, error) {
@@ -403,14 +375,20 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
return "", fmt.Errorf("invalid GPG key ID: %w", err) return "", fmt.Errorf("invalid GPG key ID: %w", err)
} }
// Use GPG to get the full fingerprint for the key // Use GPG to get the full fingerprint for the key. --status-fd 1 adds
// gpg's status lines to the output.
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
context.Background(), context.Background(),
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID, "gpg", "--status-fd", "1",
"--list-keys", "--with-colons", "--fingerprint", keyID,
) )
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
if strings.Contains(string(output), gpgNoPublicKeyStatus) {
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
}
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err) return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
} }
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// The GPG key ID and fingerprint passed to CreatePGPUnlocker. // The GPG key ID and fingerprint passed to CreatePGPUnlocker.
-193
View File
@@ -1,26 +1,18 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
var ( var (
// errSecretNotFound carries only the message tail; callers compose
// "secret <name> not found" around it so the emitted text is
// unchanged.
errSecretNotFound = errors.New("not found")
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
errGetEncryptedDataDeprecated = errors.New( errGetEncryptedDataDeprecated = errors.New(
"GetEncryptedData is deprecated - use version-specific methods") "GetEncryptedData is deprecated - use version-specific methods")
errGetCurrentVaultNotRegistered = errors.New( errGetCurrentVaultNotRegistered = errors.New(
@@ -81,73 +73,6 @@ func NewSecret(vault VaultInterface, name string) *Secret {
} }
} }
// GetValue retrieves and decrypts the current version's value, with the
// vault's long-term key derived from mnemonic when it is not nil, else
// obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// Check if secret exists
exists, err := s.Exists()
if err != nil {
Debug("Failed to check if secret exists during GetValue",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
Debug("Secret not found during GetValue",
"secret_name", s.Name, "vault_name", s.vault.GetName())
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
}
Debug("Secret exists, getting current version", "secret_name", s.Name)
// Get current version
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
// Create version object
version := NewVersion(s.vault, s.Name, currentVersion)
if mnemonic != nil {
return s.getValueViaMnemonic(version, mnemonic.String())
}
Debug("Using unlocker for vault access", "secret_name", s.Name)
// Use the provided unlocker to get the vault's long-term private key
if unlocker == nil {
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
return nil, errUnlockerRequired
}
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
if err != nil {
return nil, err
}
DebugWith("Successfully obtained vault's long-term key",
slog.String("secret_name", s.Name),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
// LoadMetadata is deprecated - metadata is now per-version and encrypted // LoadMetadata is deprecated - metadata is now per-version and encrypted
func (s *Secret) LoadMetadata() error { func (s *Secret) LoadMetadata() error {
Debug("LoadMetadata called but is deprecated in versioned model", Debug("LoadMetadata called but is deprecated in versioned model",
@@ -215,124 +140,6 @@ func (s *Secret) Exists() (bool, error) {
return true, nil return true, nil
} }
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic for direct long-term key derivation",
"secret_name", s.Name)
// Get vault directory to read metadata
vaultDir, err := s.vault.GetDirectory()
if err != nil {
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Load vault metadata to get the correct derivation index
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
if err != nil {
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
DebugWith("Using vault derivation index from metadata",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
)
// Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil {
Debug("Failed to derive long-term key from mnemonic for secret",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
// the vault's long-term private key.
func (s *Secret) getLongTermIdentityFromUnlocker(
unlocker Unlocker,
) (*age.X25519Identity, error) {
DebugWith("Getting vault's long-term key using unlocker",
slog.String("secret_name", s.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Step 1: Use the unlocker to get the vault's long-term private key
unlockIdentity, err := unlocker.GetIdentity()
if err != nil {
Debug("Failed to get unlocker identity",
"error", err, "secret_name", s.Name,
"unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read the encrypted long-term private key from the unlocker directory
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted long-term private key",
"error", err, "path", encryptedLtPrivKeyPath)
return nil, fmt.Errorf(
"failed to read encrypted long-term private key: %w", err)
}
// Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
// Parse the long-term private key
Debug("Parsing long-term private key", "secret_name", s.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse long-term private key",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}
// GetCurrentVault gets the current vault from the file system // GetCurrentVault gets the current vault from the file system
// This function is a wrapper around the actual implementation in the vault package // This function is a wrapper around the actual implementation in the vault package
// and exists to break the import cycle. // and exists to break the import cycle.
+9 -46
View File
@@ -2,7 +2,6 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,10 +9,9 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonicValue is the standard BIP39 test vector mnemonic. // testMnemonicValue is the standard BIP39 test vector mnemonic.
@@ -27,6 +25,14 @@ var (
errNotImplementedInMock = errors.New("not implemented in mock") errNotImplementedInMock = errors.New("not implemented in mock")
) )
// TestMain makes passphrase encryption in the tests cheap; see
// ScryptWorkFactor.
func TestMain(m *testing.M) {
ScryptWorkFactor = 1
os.Exit(m.Run())
}
// MockVault is a test implementation of the VaultInterface // MockVault is a test implementation of the VaultInterface
type MockVault struct { type MockVault struct {
name string name string
@@ -321,46 +327,3 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
t.Logf("Secret.Exists() works correctly") t.Logf("Secret.Exists() works correctly")
}) })
} }
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// GetValue, given the mnemonic, derives the long-term key at the derivation
// index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vaultDir := "/test-config/vaults.d/test-vault"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
vlt := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 1,
mnemonic: mnemonic,
}
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)
secretName, secretValue := "x", "value"
err = vlt.AddSecret(secretName,
memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, secretValue, value.String())
}
+51 -37
View File
@@ -4,6 +4,7 @@ package secret
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -11,9 +12,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/macse"
) )
const ( const (
@@ -129,17 +130,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID implements Unlocker interface. // GetID implements Unlocker interface: the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
hostname, err := os.Hostname() return filepath.Base(s.Directory)
if err != nil {
hostname = "unknown"
}
createdAt := s.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
} }
// Remove implements Unlocker interface. // Remove implements Unlocker interface.
@@ -216,16 +209,13 @@ func generateSEKeyLabel(vaultName string) (string, error) {
// 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 // The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes. // current unlocker, as getLongTermKeyForSE describes.
// The SE key is created once the long-term key is in hand and the unlocker's
// path is known, and is deleted again if a later step fails.
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(
fs afero.Fs, fs afero.Fs,
stateDir string, stateDir string,
mnemonic, passphrase *memguard.LockedBuffer, mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
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)
@@ -237,17 +227,7 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to generate SE key label: %w", err) return nil, fmt.Errorf("failed to generate SE key label: %w", err)
} }
// Step 1: Create P-256 key in the Secure Enclave via sc_auth // Step 1: Get the vault's long-term private key
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -257,16 +237,7 @@ func CreateSecureEnclaveUnlocker(
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES) // Step 2: Prepare the unlocker directory's path
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
// Step 4: Prepare the unlocker directory's path and metadata
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)
@@ -275,6 +246,49 @@ func CreateSecureEnclaveUnlocker(
unlockerDirName := "se-" + filepath.Base(seKeyLabel) unlockerDirName := "se-" + filepath.Base(seKeyLabel)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
ltPrivKeyData)
if err != nil {
deleteErr := macse.DeleteKey(seKeyHash)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
}
return nil, err
}
return unlocker, nil
}
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
// new unlocker into unlockerDir (steps 4 and 5 of
// CreateSecureEnclaveUnlocker).
func writeSEUnlocker(
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
ltPrivKeyData *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
// prepare the metadata
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
seMetadata := SecureEnclaveUnlockerMetadata{ seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType, Type: seUnlockerType,
+3 -2
View File
@@ -4,6 +4,7 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -67,9 +68,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID returns the unlocker ID. // GetID returns the unlocker ID, the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType return filepath.Base(s.Directory)
} }
// Remove returns an error on non-Darwin platforms. // Remove returns an error on non-Darwin platforms.
+2 -3
View File
@@ -35,9 +35,8 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
// Test GetDirectory returns the directory we passed in // Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory()) assert.Equal(t, dir, unlocker.GetDirectory())
// Test GetID returns a formatted string with the creation timestamp // Test GetID returns the name of the unlocker's directory
expectedID := "2026-01-15.10.30-secure-enclave" assert.Equal(t, "test-se-unlocker", unlocker.GetID())
assert.Equal(t, expectedID, unlocker.GetID())
} }
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
+10 -6
View File
@@ -4,6 +4,8 @@
package secret package secret
import ( import (
"os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -61,11 +63,9 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
} }
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type // The ID is the name of the unlocker's directory
assert.Contains(t, id, "2026-03-10.14.30") assert.Equal(t, "test", unlocker.GetID())
assert.Contains(t, id, seUnlockerType)
} }
func TestGenerateSEKeyLabel(t *testing.T) { func TestGenerateSEKeyLabel(t *testing.T) {
@@ -108,6 +108,10 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
// GetIdentity should fail because the encrypted longterm key file is missing // GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity() identity, err := unlocker.GetIdentity()
assert.Nil(t, identity) assert.Nil(t, identity)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key") var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
} }
+1 -1
View File
@@ -10,6 +10,6 @@ type Unlocker interface {
GetType() string GetType() string
GetMetadata() UnlockerMetadata GetMetadata() UnlockerMetadata
GetDirectory() string GetDirectory() string
GetID() string // Generate ID based on unlocker type and data GetID() string // The name of the unlocker's directory, unique in its vault
Remove() error // Remove the unlocker and any associated resources Remove() error // Remove the unlocker and any associated resources
} }
+12 -7
View File
@@ -22,10 +22,10 @@ const (
maxVersionsPerDay = 999 maxVersionsPerDay = 999
) )
var ( var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
errNilValueBuffer = errors.New("value buffer is nil") // ErrNilValueBuffer is returned when a secret's value is given as nil.
) var ErrNilValueBuffer = errors.New("value buffer is nil")
// VersionMetadata contains information about a secret version // VersionMetadata contains information about a secret version
type VersionMetadata struct { type VersionMetadata struct {
@@ -138,7 +138,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// process dies part-way. // 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
} }
DebugWith("Saving secret version", DebugWith("Saving secret version",
@@ -557,13 +557,18 @@ func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error)
} }
// 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"). If it
// cannot be read, the error says how to make a version current again: the
// versions themselves are not in the file.
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) { func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
fileData, err := afero.ReadFile(fs, currentPath) fileData, err := afero.ReadFile(fs, currentPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read current version file: %w", err) return "", fmt.Errorf("failed to read current version file: %w; "+
"this file only names the current version: 'secret version list' "+
"lists the secret's versions, and 'secret version promote' makes "+
"one of them current", err)
} }
version := strings.TrimSpace(string(fileData)) version := strings.TrimSpace(string(fileData))
+31
View File
@@ -0,0 +1,31 @@
package secret
import (
"fmt"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
func TestGenerateVersionNameMaxSerial(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
versionsDir := filepath.Join(secretDir, "versions")
// Create 999 versions
today := time.Now().Format("20060102")
for i := 1; i <= 999; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
require.NoError(t, err)
}
// Try to create one more - should fail
_, err := GenerateVersionName(fs, secretDir)
require.ErrorIs(t, err, errMaxVersionsPerDay)
}
+1 -23
View File
@@ -36,17 +36,16 @@ package secret_test
import ( import (
"errors" "errors"
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -127,27 +126,6 @@ func TestGenerateVersionName(t *testing.T) {
assert.NotEqual(t, version1, version2) assert.NotEqual(t, version1, version2)
} }
func TestGenerateVersionNameMaxSerial(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
versionsDir := filepath.Join(secretDir, "versions")
// Create 999 versions
today := time.Now().Format("20060102")
for i := 1; i <= 999; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
require.NoError(t, err)
}
// Try to create one more - should fail
_, err := secret.GenerateVersionName(fs, secretDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
}
func TestNewVersion(t *testing.T) { func TestNewVersion(t *testing.T) {
t.Parallel() t.Parallel()
-3
View File
@@ -36,9 +36,6 @@ var (
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker". // it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker") ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil")
// ErrInvalidSecretName indicates a secret name that breaks the naming // ErrInvalidSecretName indicates a secret name that breaks the naming
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty; // rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
// no leading '.' or '/', no trailing '/', no '//', no '..' path segment. // no leading '.' or '/', no trailing '/', no '//', no '..' path segment.
+138
View File
@@ -0,0 +1,138 @@
package vault_test
import (
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// otherMnemonic is a valid BIP39 mnemonic other than testMnemonic.
otherMnemonic = "legal winner thank year wave sausage worth useful " +
"legal winner thank yellow"
// missingName names no vault, secret or unlocker.
missingName = "missing"
)
// newErrorTestVault creates the vault testVaultName, with the secret
// testSecretName in it, on a new in-memory filesystem.
func newErrorTestVault(t *testing.T) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(afero.NewMemMapFs(), testStateDir,
testVaultName, testMnemonicBuffer(t), nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
t.Cleanup(value.Destroy)
require.NoError(t, vlt.AddSecret(testSecretName, value, false))
return vlt
}
// TestVaultErrors checks that each failure returns its exported error,
// wrapped or not, so that errors.Is tells it apart from the others.
func TestVaultErrors(t *testing.T) {
t.Parallel()
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
tests := []struct {
name string
run func(vlt *vault.Vault) error
want error
}{
{"create an existing vault", func(vlt *vault.Vault) error {
_, err := vault.CreateVault(vlt.GetFilesystem(), testStateDir,
testVaultName, nil, nil)
return err
}, vault.ErrVaultExists},
{"select a missing vault", func(vlt *vault.Vault) error {
return vault.SelectVault(vlt.GetFilesystem(), testStateDir, missingName)
}, vault.ErrVaultNotFound},
{"add a nil value", func(vlt *vault.Vault) error {
return vlt.AddSecret(missingName, nil, false)
}, secret.ErrNilValueBuffer},
{"get a missing secret", func(vlt *vault.Vault) error {
_, err := vlt.GetSecret(missingName)
return err
}, vault.ErrSecretNotFound},
{"copy onto an existing secret", func(vlt *vault.Vault) error {
return vlt.CopySecretAllVersions(vlt, testSecretName, testSecretName, false)
}, vault.ErrSecretExists},
{"copy a secret without versions", func(vlt *vault.Vault) error {
const versionless = "versionless"
err := vlt.GetFilesystem().MkdirAll(
filepath.Join(vaultDir, "secrets.d", versionless), secret.DirPerms)
if err != nil {
return err
}
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
}, vault.ErrNoVersions},
{"remove a missing unlocker", func(vlt *vault.Vault) error {
return vlt.RemoveUnlocker(missingName)
}, vault.ErrUnlockerNotFound},
{"select a missing unlocker", func(vlt *vault.Vault) error {
return vlt.SelectUnlocker(missingName)
}, vault.ErrUnlockerNotFound},
{"unlocker of an unknown type", func(vlt *vault.Vault) error {
fs := vlt.GetFilesystem()
err := afero.WriteFile(fs,
filepath.Join(vaultDir, "unlockers.d", "odd", "unlocker-metadata.json"),
[]byte(`{"type":"odd"}`), secret.FilePerms)
if err != nil {
return err
}
err = afero.WriteFile(fs, filepath.Join(vaultDir, "current-unlocker"),
[]byte("odd"), secret.FilePerms)
if err != nil {
return err
}
_, err = vlt.GetCurrentUnlocker()
return err
}, vault.ErrUnsupportedUnlockerType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.ErrorIs(t, tt.run(newErrorTestVault(t)), tt.want)
})
}
}
// TestGetSecretWithWrongMnemonic checks that getting a secret that exists,
// from a vault the given mnemonic does not open, fails with
// ErrMnemonicMismatch through GetSecret's wrapping, and not with
// ErrSecretNotFound.
func TestGetSecretWithWrongMnemonic(t *testing.T) {
t.Parallel()
created := newErrorTestVault(t)
mnemonic := memguard.NewBufferFromBytes([]byte(otherMnemonic))
t.Cleanup(mnemonic.Destroy)
vlt := vault.NewVault(created.GetFilesystem(), testStateDir, testVaultName)
vlt.SetMnemonic(mnemonic)
_, err := vlt.GetSecret(testSecretName)
require.ErrorIs(t, err, vault.ErrMnemonicMismatch)
require.NotErrorIs(t, err, vault.ErrSecretNotFound)
}
+6 -5
View File
@@ -2,16 +2,17 @@ package vault_test
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// deriveVaultIdentity derives the long-term identity for the given vault // deriveVaultIdentity derives the long-term identity for the given vault
@@ -341,9 +342,9 @@ 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), nil) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err == nil { if !errors.Is(err, vault.ErrInvalidVaultName) {
t.Errorf("Expected error creating vault with invalid name %q, "+ t.Errorf("Expected ErrInvalidVaultName creating vault with "+
"but got none", name) "invalid name %q, got %v", name, err)
} }
} }
} }
+7 -9
View File
@@ -30,12 +30,12 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// errUnexpectedValue is returned by concurrent readers when a secret value // errUnexpectedValue is returned by concurrent readers when a secret value
@@ -320,10 +320,10 @@ func testVersionSerialLimits(
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755) err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
require.NoError(t, err) require.NoError(t, err)
// Should fail to create 1000th version // Should fail to create 1000th version. The error is unexported in
// package secret, whose own test checks that it is the one returned.
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir)) _, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
} }
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) { func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
@@ -331,20 +331,18 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
// Try to get non-existent version // Try to get non-existent version
_, err := vault.GetSecretVersion(secretName, "99991231.999") _, err := vault.GetSecretVersion(secretName, "99991231.999")
require.Error(t, err) require.ErrorIs(t, err, ErrVersionNotFound)
assert.Contains(t, err.Error(), "not found")
// Try to get version of non-existent secret // Try to get version of non-existent secret
_, err = vault.GetSecretVersion("nonexistent/secret", "") _, err = vault.GetSecretVersion("nonexistent/secret", "")
require.Error(t, err) require.ErrorIs(t, err, ErrSecretNotFound)
// Try to add secret without force when it exists // Try to add secret without force when it exists
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail")) failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
defer failBuffer.Destroy() defer failBuffer.Destroy()
err = vault.AddSecret(secretName, failBuffer, false) err = vault.AddSecret(secretName, failBuffer, false)
require.Error(t, err) require.ErrorIs(t, err, ErrSecretExists)
assert.Contains(t, err.Error(), "already exists")
} }
// TestVersionConcurrency tests concurrent version operations // TestVersionConcurrency tests concurrent version operations
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// lockFileName is the file in the state directory that LockStateDir locks. // lockFileName is the file in the state directory that LockStateDir locks.
+2 -2
View File
@@ -5,11 +5,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+2 -2
View File
@@ -9,10 +9,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Register the GetCurrentVault function with the secret package // Register the GetCurrentVault function with the secret package
+2 -2
View File
@@ -7,9 +7,9 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Metadata is an alias for secret.VaultMetadata // Metadata is an alias for secret.VaultMetadata
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
//nolint:paralleltest // subtests share an in-memory filesystem sequentially //nolint:paralleltest // subtests share an in-memory filesystem sequentially
+5 -8
View File
@@ -3,10 +3,10 @@ package vault_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion // TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
@@ -41,10 +41,8 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Parallel() t.Parallel()
_, err := vlt.GetSecretVersion(name, "") _, err := vlt.GetSecretVersion(name, "")
require.Error(t, err, require.ErrorIs(t, err, vault.ErrInvalidSecretName,
"GetSecretVersion should reject malicious name: %s", name) "GetSecretVersion should reject malicious name: %s", name)
require.Contains(t, err.Error(), "invalid secret name",
"error should indicate invalid name for: %s", name)
}) })
} }
} }
@@ -61,8 +59,7 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd") _, err = vlt.GetSecret("../../../etc/passwd")
require.Error(t, err) require.ErrorIs(t, err, vault.ErrInvalidSecretName)
require.Contains(t, err.Error(), "invalid secret name")
} }
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject // TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
@@ -87,8 +84,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Parallel() t.Parallel()
_, err := vlt.GetSecretObject(name) _, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name) require.ErrorIs(t, err, vault.ErrInvalidSecretName,
require.Contains(t, err.Error(), "invalid secret name") "GetSecretObject should reject: %s", name)
}) })
} }
} }
+2 -2
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// ListSecrets returns a list of secret names in this vault // ListSecrets returns a list of secret names in this vault
@@ -130,7 +130,7 @@ func ValidateSecretName(name string) error {
// AddSecret adds a secret to this vault // AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error { func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil { if value == nil {
return ErrNilValueBuffer return secret.ErrNilValueBuffer
} }
secret.DebugWith("Adding secret to vault", secret.DebugWith("Adding secret to vault",
+4 -6
View File
@@ -27,12 +27,12 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the mnemonic used to derive the vault long-term key. // testMnemonic is the mnemonic used to derive the vault long-term key.
@@ -143,8 +143,7 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
defer failBuffer.Destroy() defer failBuffer.Destroy()
err := vault.AddSecret(testSecretPath, failBuffer, false) err := vault.AddSecret(testSecretPath, failBuffer, false)
require.Error(t, err) require.ErrorIs(t, err, ErrSecretExists)
assert.Contains(t, err.Error(), "already exists")
// Add with force - should create new version // Add with force - should create new version
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true) addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
@@ -309,8 +308,7 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
// Try to get non-existent version // Try to get non-existent version
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999") _, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
require.Error(t, err) require.ErrorIs(t, err, ErrVersionNotFound)
assert.Contains(t, err.Error(), "not found")
} }
func TestUpdateVersionMetadata(t *testing.T) { func TestUpdateVersionMetadata(t *testing.T) {
+8 -8
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// Unlocker metadata type strings. // Unlocker metadata type strings.
@@ -188,8 +188,9 @@ func (v *Vault) findUnlockerByID(
return nil, skippedDirPath, nil return nil, skippedDirPath, nil
} }
// ListUnlockers returns a list of available unlockers for this vault // ListUnlockers returns the metadata of each unlocker of this vault, keyed
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) { // by the unlocker's ID, the name of its directory in unlockers.d
func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -204,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
} }
if !exists { if !exists {
return []UnlockerMetadata{}, nil return map[string]UnlockerMetadata{}, nil
} }
// List directories in unlockers.d // List directories in unlockers.d
@@ -213,7 +214,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err) return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
} }
var unlockers []UnlockerMetadata unlockers := map[string]UnlockerMetadata{}
for _, file := range files { for _, file := range files {
if !file.IsDir() { if !file.IsDir() {
@@ -222,7 +223,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name()) metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok { if ok {
unlockers = append(unlockers, metadata) unlockers[file.Name()] = metadata
} }
} }
@@ -453,8 +454,7 @@ func writePassphraseUnlocker(
return nil, err return nil, err
} }
// Select the new unlocker by its directory, not by its ID: an old // Make the new unlocker the current one
// passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
err = secret.WriteFileAtomic(fs, currentUnlockerPath, err = secret.WriteFileAtomic(fs, currentUnlockerPath,
+39 -4
View File
@@ -1,15 +1,16 @@
package vault package vault
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Vault represents a secrets vault // Vault represents a secrets vault
@@ -98,7 +99,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
if err != nil { if err != nil {
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name) secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, v.withMnemonicAdvice(
fmt.Errorf("failed to get current unlocker: %w", err))
} }
secret.DebugWith("Retrieved current unlocker for vault unlock", secret.DebugWith("Retrieved current unlocker for vault unlock",
@@ -112,7 +114,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// Other unlockers return their own identity, used to decrypt longterm.age. // Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker) ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil { if err != nil {
return nil, err return nil, v.withMnemonicAdvice(err)
} }
secret.DebugWith("Successfully obtained long-term identity via unlocker", secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -295,3 +297,36 @@ func (v *Vault) unlockLongTermKey(
return ltIdentity, nil return ltIdentity, nil
} }
// withMnemonicAdvice returns err, a failure to get the long-term key through
// the current unlocker, with advice added: that the mnemonic still opens the
// vault, and how to give it a new unlocker. The advice is added only when the
// vault metadata records the key that the mnemonic derives; a vault created
// without a mnemonic records none, and without its metadata the key cannot
// be derived. It is not added when the passphrase could not be read: the
// unlocker was not tried, and adding one would need a passphrase read the
// same way.
func (v *Vault) withMnemonicAdvice(err error) error {
if errors.Is(err, secret.ErrPassphraseNotRead) {
return err
}
vaultDir, _ := v.GetDirectory()
metadata, metadataErr := LoadVaultMetadata(v.fs, vaultDir)
if metadataErr != nil || metadata.PublicKeyHash == "" {
return err
}
// 'secret unlocker add' acts on the current vault only.
steps := "'secret unlocker add passphrase'"
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
if currentErr != nil || current.Name != v.Name {
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
}
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
"%s with %s set to the mnemonic to give it a new unlocker",
err, v.Name, steps, secret.EnvMnemonic)
}
+10 -4
View File
@@ -1,15 +1,16 @@
package vault_test package vault_test
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) { func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
@@ -36,8 +37,13 @@ func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
defer value.Destroy() defer value.Destroy()
err := vlt.AddSecret(testSecretName, value, false) err := vlt.AddSecret(testSecretName, value, false)
require.Error(t, err, "AddSecret should fail when public key is missing")
assert.Contains(t, err.Error(), "failed to read long-term public key") var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist,
"AddSecret should fail when public key is missing")
assert.Equal(t, filepath.Join(vaultDir, "pub.age"), cause.Path)
// Verify that the secret directory was NOT created // Verify that the secret directory was NOT created
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName) secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
+12 -3
View File
@@ -3,15 +3,16 @@ package vault_test
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the shared BIP39 test mnemonic for tests in this package. // testMnemonic is the shared BIP39 test mnemonic for tests in this package.
@@ -28,6 +29,14 @@ const (
testPassphrase = "test-passphrase" testPassphrase = "test-passphrase"
) )
// TestMain makes passphrase encryption in the tests cheap; see
// secret.ScryptWorkFactor.
func TestMain(m *testing.M) {
secret.ScryptWorkFactor = 1
os.Exit(m.Run())
}
// testMnemonicBuffer returns testMnemonic in a locked buffer that is // testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends. // destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer { func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+35 -18
View File
@@ -1,14 +1,21 @@
# agehd - Deterministic Age Identities from BIP85 # agehd - Deterministic Age Identities from BIP85
The `agehd` package derives deterministic X25519 age identities using BIP85 entropy derivation and a deterministic random number generator (DRNG). This package only supports proper BIP85 sources: BIP39 mnemonics and extended private keys (xprv). The `agehd` package derives deterministic X25519 age identities using BIP85
entropy derivation and a deterministic random number generator (DRNG). This
package only supports proper BIP85 sources: BIP39 mnemonics and extended private
keys (xprv).
## Features ## Features
- **Deterministic key generation**: Same input always produces the same age identity - **Deterministic key generation**: Same input always produces the same age
identity
- **BIP85 compliance**: Uses the BIP85 standard for entropy derivation - **BIP85 compliance**: Uses the BIP85 standard for entropy derivation
- **Multiple key support**: Generate multiple keys from the same source using different indices - **Multiple key support**: Generate multiple keys from the same source using
- **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private keys (xprv) different indices
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid conflicts - **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private
keys (xprv)
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid
conflicts
## Derivation Path ## Derivation Path
@@ -19,6 +26,7 @@ m/83696968'/592366788'/733482323'/n'
``` ```
Where: Where:
- `83696968'` is the BIP85 root path ("bip" in ASCII) - `83696968'` is the BIP85 root path ("bip" in ASCII)
- `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff) - `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff)
- `733482323'` is the application ID (sha256("secret") & 0x7fffffff) - `733482323'` is the application ID (sha256("secret") & 0x7fffffff)
@@ -35,7 +43,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "sneak.berlin/go/secret/pkg/agehd"
) )
func main() { func main() {
@@ -61,7 +69,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "sneak.berlin/go/secret/pkg/agehd"
) )
func main() { func main() {
@@ -87,7 +95,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "sneak.berlin/go/secret/pkg/agehd"
) )
func main() { func main() {
@@ -114,7 +122,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "sneak.berlin/go/secret/pkg/agehd"
) )
func main() { func main() {
@@ -151,7 +159,8 @@ Derives a deterministic age identity from a BIP39 mnemonic and index.
#### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)` #### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from an extended private key (xprv) and index. Derives a deterministic age identity from an extended private key (xprv) and
index.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index (0, 1, 2, ...) - `n`: The derivation index (0, 1, 2, ...)
@@ -167,7 +176,8 @@ Derives 32 bytes of entropy from a BIP39 mnemonic and index using BIP85.
#### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)` #### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from an extended private key (xprv) and index using BIP85. Derives 32 bytes of entropy from an extended private key (xprv) and index using
BIP85.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index - `n`: The derivation index
@@ -182,20 +192,27 @@ Converts 32 bytes of entropy into an age X25519 identity.
## Implementation Details ## Implementation Details
1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source 1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive
2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy 64 bytes of entropy from the input source
3. **Key Generation**: 32 bytes are read from the DRNG to generate the age private key 2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for X25519 is seeded with the 64-byte entropy
5. **Bech32 Encoding**: The key is encoded using Bech32 with the "age-secret-key-" prefix 3. **Key Generation**: 32 bytes are read from the DRNG to generate the age
private key
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for
X25519
5. **Bech32 Encoding**: The key is encoded using Bech32 with the
"age-secret-key-" prefix
## Security Considerations ## Security Considerations
- The same mnemonic/xprv and index will always produce the same identity - The same mnemonic/xprv and index will always produce the same identity
- Different indices produce cryptographically independent identities - Different indices produce cryptographically independent identities
- The vendor/application scoping prevents conflicts with other BIP85 applications - The vendor/application scoping prevents conflicts with other BIP85
applications
- The DRNG ensures high-quality randomness for key generation - The DRNG ensures high-quality randomness for key generation
- Private keys are properly clamped for X25519 usage - Private keys are properly clamped for X25519 usage
- Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary passphrases - Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary
passphrases
## Testing ## Testing
+1 -1
View File
@@ -14,11 +14,11 @@ import (
"strings" "strings"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
) )
const ( const (
+5 -18
View File
@@ -38,7 +38,6 @@ const (
testMessageLargePattern = "A" testMessageLargePattern = "A"
// Error messages for validation // Error messages for validation
errorMsgNeed32Bytes = "need 32-byte scalar, got"
errorMsgInvalidXPRV = "invalid-xprv" errorMsgInvalidXPRV = "invalid-xprv"
// Test constants for various scenarios // Test constants for various scenarios
@@ -330,24 +329,17 @@ func TestClampFunction(t *testing.T) {
} }
} }
// requireIdentityError asserts that identity derivation failed with an // requireIdentityError asserts that identity derivation failed with
// error containing errorMsg and returned no identity. // errInvalidScalarSize and returned no identity.
func requireIdentityError( func requireIdentityError(
t *testing.T, t *testing.T,
identity *age.X25519Identity, identity *age.X25519Identity,
err error, err error,
errorMsg string,
) { ) {
t.Helper() t.Helper()
if err == nil { if !errors.Is(err, errInvalidScalarSize) {
t.Errorf("expected error but got none") t.Errorf("expected errInvalidScalarSize, got %v", err)
} else if !strings.Contains(err.Error(), errorMsg) {
t.Errorf(
"expected error containing %q, got %q",
errorMsg,
err.Error(),
)
} }
if identity != nil { if identity != nil {
@@ -363,31 +355,26 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
name string name string
entropy []byte entropy []byte
expectError bool expectError bool
errorMsg string
}{ }{
{ {
name: "nil entropy", name: "nil entropy",
entropy: nil, entropy: nil,
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
}, },
{ {
name: "empty entropy", name: "empty entropy",
entropy: []byte{}, entropy: []byte{},
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
}, },
{ {
name: "too short entropy", name: "too short entropy",
entropy: make([]byte, 31), entropy: make([]byte, 31),
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 31",
}, },
{ {
name: "too long entropy", name: "too long entropy",
entropy: make([]byte, 33), entropy: make([]byte, 33),
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 33",
}, },
{ {
name: "valid 32-byte entropy", name: "valid 32-byte entropy",
@@ -419,7 +406,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
identity, err := IdentityFromEntropy(tt.entropy) identity, err := IdentityFromEntropy(tt.entropy)
if tt.expectError { if tt.expectError {
requireIdentityError(t, identity, err, tt.errorMsg) requireIdentityError(t, identity, err)
return return
} }
+11 -4
View File
@@ -1,10 +1,15 @@
# BIP85 - Deterministic Entropy From BIP32 Keychains # BIP85 - Deterministic Entropy From BIP32 Keychains
This package implements [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which allows for deterministic derivation of entropy from a BIP32 master key. This enables a single seed to generate multiple wallet keys, mnemonics, and random values in a fully deterministic way. This package implements
[BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which
allows for deterministic derivation of entropy from a BIP32 master key. This
enables a single seed to generate multiple wallet keys, mnemonics, and random
values in a fully deterministic way.
## Overview ## Overview
BIP85 enables a variety of use cases: BIP85 enables a variety of use cases:
- Generate multiple BIP39 mnemonic seeds from a single master key - Generate multiple BIP39 mnemonic seeds from a single master key
- Derive Bitcoin HD wallet seeds (WIF format) - Derive Bitcoin HD wallet seeds (WIF format)
- Create extended private keys (XPRV) - Create extended private keys (XPRV)
@@ -17,8 +22,8 @@ BIP85 enables a variety of use cases:
```go ```go
import ( import (
"fmt" "fmt"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"sneak.berlin/go/secret/pkg/bip85"
) )
// Parse an existing master key // Parse an existing master key
@@ -114,6 +119,7 @@ m/83696968'/{app}'/{parameters}
``` ```
Where: Where:
- `83696968'` is the BIP85 root path (BIP in ASCII) - `83696968'` is the BIP85 root path (BIP in ASCII)
- `{app}'` is the application number: - `{app}'` is the application number:
- `39'` for BIP39 mnemonics - `39'` for BIP39 mnemonics
@@ -135,12 +141,13 @@ This implementation passes all the test vectors from the BIP85 specification:
- XPRV - XPRV
- SHAKE256 DRNG output - SHAKE256 DRNG output
The implementation is also compatible with the Python reference implementation's test vectors for the DRNG functionality. The implementation is also compatible with the Python reference implementation's
test vectors for the DRNG functionality.
Run the tests with verbose output to see the test vectors and results: Run the tests with verbose output to see the test vectors and results:
``` ```
go test -v git.eeqj.de/sneak/secret/pkg/bip85 go test -v sneak.berlin/go/secret/pkg/bip85
``` ```
## References ## References

Some files were not shown because too many files have changed in this diff Show More