Author SHA1 Message Date
sneak 655a112e74 A wrong passphrase gives an error that says so (closes #157)
check / check (push) Waiting to run
A passphrase that did not decrypt the passphrase unlocker failed with age's
"no identity matched any of the recipients" and advice to give the vault a
new unlocker from its mnemonic, which would replace an unlocker that works.
GetIdentity now turns that age error into "wrong passphrase for unlocker
<id>", wrapping the new secret.ErrWrongPassphrase, and withMnemonicAdvice
leaves it alone, as it does when no passphrase was read. A passphrase from
SB_UNLOCK_PASSPHRASE and a typed one both reach this code. A priv.age that
is not an age file keeps the advice.

Judgement call: age cannot tell a wrong passphrase from a damaged key inside
a priv.age whose header reads, so that case now reads as a wrong passphrase.

Model: opus-5-5
2026-10-14 21:23:59 +00:00
clawbot 46c395e045 Ask for the passphrase on the controlling terminal, not stdin (closes #144)
check / check (push) Waiting to run
The passphrase and mnemonic prompts open /dev/tty and ask there, so
`echo v | secret add s1 --force`, `secret encrypt` and `secret decrypt`
with piped input ask at a terminal instead of failing. Only a process
with no controlling terminal still gets the error naming the variable to
set. `secret init` and `secret vault create` no longer write a newline
to stderr after the mnemonic.

On SIGINT or SIGTERM, Entry() restores the settings of /dev/tty instead
of stdin, since that is where the prompt turns echo off.

The two tests that pin the no-terminal message run the binary in a new
session, so it has no terminal wherever the tests run.

Unverified by a test: the echo restore after Ctrl-C, checked by hand.

Model: opus-5-5
2026-10-14 23:02:23 +02:00
clawbot fe94e99801 Print the mnemonic and the vault and unlocker listings to stdout (closes #138)
check / check (push) Waiting to run
`secret generate mnemonic`, `secret vault list` and `secret unlocker list`,
in text and `--json` form, printed through cobra's `cmd.Println` and
`cmd.Printf`. Those write to stderr when no output writer is set, and the
binary sets none, so a pipe or `$(...)` got nothing. They now write to
`cmd.OutOrStdout()`, as `secret list` does, and so does "No versions found"
from `secret version list`. Status messages and the warning printed after
the mnemonic stay on stderr.

A new test runs the built binary and reads the stdout of `generate
mnemonic` and of both listings in both forms.

Model: opus-5-5
2026-10-14 22:31:47 +02:00
clawbot 0630a32167 Make another unlocker current when the current one is removed (closes #137)
check / check (push) Waiting to run
Removing the vault's current unlocker left current-unlocker naming it,
so every command that opened the vault failed although another
unlocker remained, and the error pointed at the mnemonic.
RemoveUnlocker now first makes current the first other unlocker that
`secret unlocker select` accepts, in the order `secret unlocker list`
shows, and returns its ID, which `secret unlocker remove` prints; with
none, current-unlocker is removed too. When current-unlocker names an
unlocker that does not exist and the vault has another that select
accepts, the error wraps ErrUnlockerNotFound and says to run
`secret unlocker select`, without the mnemonic advice.

Unverified: no test covers that advice for a vault other than the
current one, which gets `secret vault select` first.

Model: opus-5-5
2026-10-14 22:02:16 +02:00
clawbot 5371382cb4 A failed secret add --force leaves the secret unchanged (closes #136)
check / check (push) Waiting to run
addVersion wrote the new version before it unlocked the vault to set
notAfter on the version being replaced, so a wrong passphrase or mnemonic
left an extra version behind. It now unlocks the vault and loads that
version's metadata first; writing notAfter and switching current still
come after the new version is saved. generate secret --force and
import --force take the same path.

The time set as the new version's notBefore and the replaced version's
notAfter is taken after the unlock, which can wait for the passphrase.

updatePreviousVersion asks for the long-term key again. The vault keeps
it in memory after the first unlock, so this neither prompts nor derives
it a second time.

Model: opus-5-5
2026-10-14 20:48:48 +02:00
clawbot fd6c5df1a8 Derive the vault key from the mnemonic's words however they are spaced (closes #135)
check / check (push) Failing after 4s
The long-term key was derived from the mnemonic text exactly as typed,
so a double space, a tab or a trailing newline gave a different key,
and a vault made that way did not open with the same words typed
normally. agehd.DeriveEntropy, which init, vault create, vault import,
unlocker add and every unlock go through, now joins the words with
single spaces and puts them in Unicode NFKD, as BIP39 defines the
seed's input, before hashing them. golang.org/x/text, already an
indirect dependency, becomes a direct one for the NFKD step.

Judgement call: internal/bip39's NewSeed keeps hashing its input as
given, as upstream does; the normalising is in agehd.

Model: opus-5-5
2026-10-13 01:09:36 +02:00
clawbot 2d6675a452 Remove the keychain and Secure Enclave unlockers (closes #132)
check / check (push) Failing after 5s
Neither unlocker works without macOS code signing and entitlements, which
secret does not have, so both were research and leave the build.
`secret unlocker add` takes only passphrase and pgp on every platform;
keychain and secure-enclave fail as unknown types and are gone from
--help and shell completion. internal/macse, their files in
internal/secret with their tests, and their branches in internal/vault
and internal/cli are deleted; github.com/keybase/go-keychain leaves
go.mod and go.sum. README and Dockerfile comments no longer mention
them. The research stays at ef0ae90, tagged v1.0.0-rc1.

Judgement call: derivation_index_test.go goes with getLongTermPrivateKey,
its only subject, which only these two unlockers used.
Unverified: not built or run on a Mac; only the GOOS=darwin vet and lint ran.

Model: opus-5-5
2026-10-13 00:39:35 +02:00
clawbot ef0ae90768 Copy go-bip39 into internal/bip39 (closes #122)
check / check (push) Failing after 5s
go-bip39's repository no longer exists. The part of v1.1.0 that secret
uses (NewEntropy, NewMnemonic, NewSeed, IsMnemonicValid and what they
call; English word list only) now lives in internal/bip39 with
upstream's LICENSE beside it, along with upstream's tests of that code
and their vectors unchanged. Every import moves there, and the module
leaves go.mod and go.sum. Beyond the trimming, only what the linter
asked for changed. No derived key or mnemonic changes.

Model: opus-5-5
2026-10-07 12:56:10 +02:00
clawbot 2c6fe7c368 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #121)
check / check (push) Canceled after 0s
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) Canceled after 0s
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) Successful in 49s
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) Successful in 3m27s
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
clawbot 015730fb05 Delete .tmp- leftovers of a killed command when the lock is next taken (closes #75)
check / check (push) Failing after 2s
A command killed part-way could leave a temporary file or directory of
secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included,
for good. LockStateDir now empties the lock file once it holds the lock
and writes "finished" there just before releasing it. A holder that
does not find that deletes such leftovers from the state directory,
each vault, each secret and each version, the only places those helpers
make them, matching names that start with "." and hold ".tmp-". After a
command that finished nothing is searched, so the added time does not
grow with the number of secrets and versions. A test shows that
`unlocker remove` removes an unlocker directory with no metadata file.

Model: opus-5-5
2026-10-04 19:25:25 +02:00
clawbot 1d7f78fd0d Add the README's required sections and clear stale TODO.md items (closes #46)
check / check (push) Failing after 2s
README gains Description, Getting Started, Rationale, Design, TODO and
License sections; its first sentence names the licence and author.
Installation and Quick Start become Getting Started; Core Architecture
becomes Design, whose two false version bullets (symlink switching,
unencrypted metadata) are corrected. README and AGENTS.md are wrapped
to prettier's settings.

TODO.md: Workflow and Next Step point at the 1.0.0 milestone and the
next branch, the old Next Step's four finished items move to Completed
Steps with their dates, and Future Steps loses the items already done.

Model: opus-5-5
2026-10-04 19:25:07 +02:00
clawbot ef79111e2e Put age identity keys into locked buffers through one function (closes #38)
check / check (push) Failing after 3s
secret.IdentityToLockedBuffer replaces the eight places that converted
an age identity's String() to bytes for a locked buffer and left the
string, which holds the private key, in ordinary memory. It moves the
string's own bytes into the buffer, which overwrites them. The copies
age makes while encoding the key remain; the function's comment says
so. TODO.md drops these places from the 1.0 memory-security entry,
along with its stale version.go reference.

Model: opus-5-5
2026-10-04 18:42:00 +02:00
clawbot 017b8d73bf Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 2s
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 18:07:56 +02:00
clawbot 1cc8653981 Ask before removing a secret, version, vault or unlocker (closes #39)
check / check (push) Failing after 2s
secret rm, secret version rm, secret vault remove and secret unlocker
remove ask [y/N] on a terminal, naming what they remove, and go ahead
only on y or yes. Without --force, a command whose stdin is not a
terminal fails at once. --force, now also on rm and version rm,
removes without asking; it replaces the old refusals to remove a vault
with secrets or the last unlocker without --force. The checks run and
the question is asked before the state directory lock is taken; under
the lock the checks run again, and nothing is removed if they would
ask a different question.

Model: opus-5-5
2026-10-04 17:41:48 +02:00
clawbot 7e4e0f7806 Give every new unlocker a directory of its own (closes #71)
check / check (push) Failing after 1s
A passphrase unlocker added to a vault that had one, and a PGP, keychain
or Secure Enclave unlocker added on the same day as another of its type,
were written into the existing unlocker's directory file by file, so a
crash part-way left a current unlocker whose files did not belong
together.

Unlocker directories, keychain items and Secure Enclave keys are now
named with the time to the nanosecond, and secret.WriteDir refuses a
directory that exists. Adding a passphrase unlocker writes the new one,
points current-unlocker at it, and only then removes the vault's other
passphrase unlockers.

Model: opus-5-5
2026-10-04 16:58:45 +02:00
clawbot db7d2c952e Read secret environment variables once per command, then unset them (closes #60)
check / check (push) Failing after 1s
init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both;
CreatePGPUnlocker sets them on the vault it loads through SetMnemonic
and SetUnlockPassphrase, new in VaultInterface. README warns against
both variables.

Model: opus-5-5
2026-10-04 16:07:57 +02:00
clawbot 5ca615a7a6 Use the canonical golangci-lint config (closes #66)
check / check (push) Failing after 1s
Replace `.golangci.yml` with the canonical file from `sneak/prompts`,
byte for byte. It runs `gomodguard_v2` in place of the deprecated
`gomodguard`, which removes the deprecation warning from every lint run,
and enables a `depguard` rule keeping `net/http/httptest` out of non-test
files. Neither raised a finding in this repo.

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

Model: opus-5-5
2026-10-04 14:42:01 +02:00
clawbot 007254a1f0 Check vault names in every command that takes one (closes #68)
check / check (push) Failing after 1s
A vault name may use only lowercase ASCII letters, digits, `.`, `-` and
`_`, and must not be empty, `.` or `..`; the error now states that rule.
`vault create`, `vault import`, `vault select`, `vault remove`, both
vault names of `mv` and shell completion of a `vault:secret` argument
check the name as typed before building any path from it. Before,
`vault import ..` wrote a long-term key and an unlocker into the state
directory itself, and `vault select ..` made that the current vault.

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

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

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

Model: opus-5-5
2026-10-04 12:58:50 +02:00
clawbot 24d99819a3 Skip corrupt unlocker metadata in unlocker select and remove (closes #72)
check / check (push) Failing after 1s
findUnlockerByID failed on the first unlocker directory whose metadata
could not be checked, read or parsed, so `secret unlocker select` and
`remove` failed when one sorted before the unlocker asked for. It now
skips such a directory with the warning ListUnlockers gives, through
the code both now share. A skipped directory is removed by its
directory name, with RemoveDirAtomic, and cannot be selected. Removing
one whose metadata file is missing or corrupt never counts as removing
the last unlocker; removing one whose metadata file cannot be checked
for or read does, since it may be the only working unlocker.

Model: opus-5-5
2026-10-04 12:42:01 +02:00
clawbot 1ec0423e6e Print usage only for a command called wrongly (closes #41)
check / check (push) Failing after 4s
A failed command printed the whole usage text after its error, burying
it. The root command's PersistentPreRunE now turns usage off, so an
error from running the command is printed once on its own. Wrong arity,
an unknown flag, a bad flag value, a missing required flag and broken
flag groups still get usage: cobra checks arguments and flag values
before that hook but required flags and flag groups only after it, so
the hook checks those two first. Root SilenceUsage was not used: in
this cobra version it hides usage for argument and flag errors too.
Cobra still prints the error; Entry is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 14:16:02 +02:00
clawbotandsneak 41cea400a7 Update golangci-lint to v2.12.2 with canonical config (#29)
check / check (push) Successful in 43s
Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image).

Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`).

The stricter config surfaced ~1550 findings, all fixed:

- `wsl_v5` (439) / `nlreturn` (24): blank-line insertions
- `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged
- `noinlineerr` (130): `if err := ...` split into assignment plus check
- `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it
- `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is`
- `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq`
- `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()`
- `noctx` (22): `exec.CommandContext` for gpg/CLI invocations
- `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint`
- `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction
- assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits`

## User-visible strings

**None changed.** Every error message this branch composes is byte-identical to the one `main` composes.

The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `vault.ErrVaultNotFound` is `"does not exist"`, composed by its caller as `vault &lt;name&gt; does not exist`); each such sentinel documents the message it participates in.

Verified mechanically, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty.

## `unlocker list`

`findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of emitting a row under a synthesized fallback ID that no `unlocker remove` or `unlocker select` can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by `internal/cli/unlockers_list_test.go`.

`TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint).

`make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing.

Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #29
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-10 15:23:33 +02:00
clawbotandsneak 6e5e0db999 Add .editorconfig (closes #27) (#28)
check / check (push) Successful in 42s
Adds `.editorconfig`, copied verbatim from `sneak/dnswatcher` (`root = true`; space/4/lf/utf-8/trim/final-newline, with `[Makefile]` overriding to tab). secret had none. Resolves #27.

`make check` is green on the host (`0 issues`, all packages pass). Note: `docker build .` fails only on a pre-existing, environmental `memguard` `mlock` / `RLIMIT_MEMLOCK` issue in the 10MB-secret test — it reproduces identically on pristine `origin/main` with this file removed, and secret's real CI sets the memlock ulimit for it. A static config file cannot affect Go tests, so this change is clean; the container ulimit is a separate pre-existing concern.

Left open for review (not merged).

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #28
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:46:27 +02:00
sneak e0e5ae68a4 scripts-to-rule-them-all (#26)
check / check (push) Successful in 38s
Reviewed-on: #26
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:13:44 +02:00
sneak 1fc11529ed TODO (#25)
check / check (push) Successful in 40s
Reviewed-on: #25
2026-07-06 21:20:29 +02:00
b090b3f86b ci: add Gitea Actions workflow for make check (#21)
check / check (push) Successful in 26s
Adds CI workflow that runs `make check` on push/PR to main.

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #21
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-30 21:34:49 +02:00
sneakandclawbot a3d3fb3b69 secure-enclave-unlocker (#24)
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #24
Reviewed-by: clawbot <clawbot@noreply.example.org>
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-03-14 07:36:28 +01:00
sneak 4dc26c9394 Merge pull request 'chore: remove stale .cursorrules and coverage.out' (#22) from chore/remove-stale-files into main
Reviewed-on: #22
2026-02-28 19:29:52 +01:00
user 7546cb094f chore: remove stale .cursorrules and coverage.out
Remove committed editor config (.cursorrules) and test coverage
artifact (coverage.out). Both added to .gitignore.
2026-02-20 02:59:23 -08:00
sneak 797d2678c8 Merge pull request 'Add secret.Warn() calls for all silent anomalous conditions' (#20) from clawbot/secret:audit/add-warnings into main
Reviewed-on: #20
2026-02-20 09:22:29 +01:00
user 78015afb35 Add secret.Warn() calls for all silent anomalous conditions
Audit of the codebase found 9 locations where errors or anomalous
conditions were silently swallowed or only logged via Debug(). Users
should be informed when something unexpected happens, even if the
program can continue.

Changes:
- DetermineStateDir: warn on config dir fallback to ~/.config
- info_helper: warn when vault/secret stats cannot be read
- unlockers list: warn on metadata read/parse failures (fixes FIXMEs)
- unlockers list: warn on fallback ID generation
- checkUnlockerExists: warn on errors during duplicate checking
- completions: warn on unlocker metadata read/parse failures
- version list: upgrade metadata load failure from Debug to Warn
- secrets: upgrade file close failure from Debug to Warn
- version naming: warn on malformed version directory names

Closes #19
2026-02-20 00:03:49 -08:00
sneak 1c330c697f Merge pull request 'Skip unlocker directories with missing metadata instead of failing (closes #1)' (#17) from clawbot/secret:fix/issue-1 into main
Reviewed-on: #17
2026-02-20 08:59:04 +01:00
sneak d18e286377 Merge branch 'main' into fix/issue-1 2026-02-20 08:58:43 +01:00
sneak f49fde3a06 Merge pull request 'Fix getLongTermPrivateKey derivation index hardcoded to 0 (closes #3)' (#8) from clawbot/secret:fix/issue-3 into main
Reviewed-on: #8
2026-02-20 08:58:21 +01:00
sneak 206651f89a Merge branch 'main' into fix/issue-3 2026-02-20 08:58:10 +01:00
user c0f221b1ca Change missing metadata log from Debug to Warn for visibility without --verbose
Per review feedback: missing unlocker metadata should produce a warning
visible in normal output, not hidden behind debug flags.
2026-02-19 23:57:39 -08:00
sneak 09be20a044 Merge pull request 'Allow uppercase letters in secret names (closes #2)' (#16) from clawbot/secret:fix/issue-2 into main
Reviewed-on: #16
2026-02-20 08:57:19 +01:00
sneak 2e1ba7d2e0 Merge branch 'main' into fix/issue-2 2026-02-20 08:57:03 +01:00
sneak 1a23016df1 Merge pull request 'Validate secret name in GetSecretVersion to prevent path traversal (closes #13)' (#15) from clawbot/secret:fix/issue-13 into main
Reviewed-on: #15
2026-02-20 08:56:51 +01:00
sneak ebe3c17618 Merge branch 'main' into fix/issue-13 2026-02-20 08:56:36 +01:00
clawbot 1a96360f6a Skip unlocker directories with missing metadata instead of failing
When an unlocker directory exists but is missing unlocker-metadata.json,
log a debug warning and skip it instead of returning a hard error that
crashes the entire 'unlocker ls' command.

Closes #1
2026-02-19 23:56:08 -08:00
sneak 4f5d2126d6 Merge pull request 'Return error from GetDefaultStateDir when home directory unavailable (closes #14)' (#18) from clawbot/secret:fix/issue-14 into main
Reviewed-on: #18
2026-02-20 08:54:22 +01:00
clawbot 6be4601763 refactor: return errors from NewCLIInstance instead of panicking
Change NewCLIInstance() and NewCLIInstanceWithFs() to return
(*Instance, error) instead of panicking on DetermineStateDir failure.

Callers in RunE contexts propagate the error. Callers in command
construction (for shell completion) use log.Fatalf. Test callers
use t.Fatalf.

Addresses review feedback on PR #18.
2026-02-19 23:53:35 -08:00
user 36ece2fca7 docs: add Go coding policies to AGENTS.md per review request 2026-02-19 23:53:23 -08:00
clawbot dc225bd0b1 fix: add blank line before return for nlreturn linter 2026-02-19 23:44:38 -08:00
clawbot 6acd57d0ec fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:32 -08:00
clawbot 596027f210 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:13 -08:00
clawbot 0aa9a52497 test: add test for getLongTermPrivateKey derivation index
Verifies that getLongTermPrivateKey reads the derivation index from
vault metadata instead of using hardcoded index 0. Test creates a
mock vault with DerivationIndex=5 and confirms the derived key
matches index 5.
2026-02-19 23:43:13 -08:00
clawbot 09ec79c57e fix: use vault derivation index in getLongTermPrivateKey instead of hardcoded 0
Previously, getLongTermPrivateKey() always used derivation index 0 when
deriving the long-term key from a mnemonic. This caused wrong key
derivation for vaults with index > 0 (second+ vault from same mnemonic),
leading to silent data corruption in keychain unlocker creation.

Now reads the vault's actual DerivationIndex from vault-metadata.json.
2026-02-19 23:43:13 -08:00
clawbot e8339f4d12 fix: update integration test to allow uppercase secret names 2026-02-19 23:42:39 -08:00
clawbot 4f984cd9c6 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:41:43 -08:00
clawbot d1caf0a208 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:40:21 -08:00
user 8eb25b98fd fix: block .. path components in secret names and validate in GetSecretObject
- isValidSecretName() now rejects names with '..' path components (e.g. foo/../bar)
- GetSecretObject() now calls isValidSecretName() before building paths
- Added test cases for mid-path traversal patterns
2026-02-15 14:17:33 -08:00
clawbot 6211b8e768 Return error from GetDefaultStateDir when home directory unavailable
When os.UserConfigDir() fails, DetermineStateDir falls back to
os.UserHomeDir(). Previously the error from UserHomeDir was discarded,
which could result in a dangerous root-relative path (/.config/...) if
both calls fail.

Now DetermineStateDir returns (string, error) and propagates failures
from both UserConfigDir and UserHomeDir.

Closes #14
2026-02-15 14:05:15 -08:00
user 0307f23024 Allow uppercase letters in secret names (closes #2)
The isValidSecretName() regex only allowed lowercase letters [a-z], rejecting
valid secret names containing uppercase characters (e.g. AWS access key IDs).

Changed regex from ^[a-z0-9\.\-\_\/]+$ to ^[a-zA-Z0-9\.\-\_\/]+$ and added
tests for uppercase secret names in both vault and secret packages.
2026-02-15 14:03:50 -08:00
clawbot 3fd30bb9e6 Validate secret name in GetSecretVersion to prevent path traversal
Add isValidSecretName() check at the top of GetSecretVersion(), matching
the existing validation in AddSecret(). Without this, crafted secret names
containing path traversal sequences (e.g. '../../../etc/passwd') could be
used to read files outside the vault directory.

Add regression tests for both GetSecretVersion and GetSecret.

Closes #13
2026-02-15 14:03:28 -08:00
sneak 6ff00c696a Merge pull request 'Remove redundant longterm.age encryption in Init command (closes #6)' (#11) from clawbot/secret:fix/issue-6 into main
Reviewed-on: #11
2026-02-09 02:39:55 +01:00
sneak c6551e4901 Merge branch 'main' into fix/issue-6 2026-02-09 02:39:41 +01:00
sneak b06d7fa3f4 Merge pull request 'Fix NumSecrets() always returning 0 (closes #4)' (#9) from clawbot/secret:fix/issue-4 into main
Reviewed-on: #9
2026-02-09 02:39:30 +01:00
sneak 16d5b237d2 Merge branch 'main' into fix/issue-4 2026-02-09 02:26:20 +01:00
sneak 660de5716a Merge pull request 'Non-darwin KeychainUnlocker stub returns errors instead of panicking (closes #7)' (#12) from clawbot/secret:fix/issue-7 into main
Reviewed-on: #12
2026-02-09 02:20:14 +01:00
sneak 51fb2805fd Merge branch 'main' into fix/issue-7 2026-02-09 02:19:56 +01:00
sneak 6ffb24b544 Merge pull request 'Zero plaintext after copying to memguard in DecryptWithIdentity (closes #5)' (#10) from clawbot/secret:fix/issue-5 into main
Reviewed-on: #10
2026-02-09 02:18:06 +01:00
clawbot 4419ef7730 fix: non-darwin KeychainUnlocker stub returns errors instead of panicking
The stub previously panicked on all methods including NewKeychainUnlocker,
which is called from vault code when processing keychain-type unlocker
metadata. This caused crashes on Linux/Windows when a vault synced from
macOS contained keychain unlockers.

Now returns proper error values, allowing graceful degradation and
cross-platform vault portability.
2026-02-08 12:05:38 -08:00
clawbot 991b1a5a0b fix: remove redundant longterm.age encryption in Init command
CreatePassphraseUnlocker already encrypts and writes the long-term
private key to longterm.age. The Init command was doing this a second
time, overwriting the file with a functionally equivalent but
separately encrypted blob. This was wasteful and a maintenance hazard.
2026-02-08 12:05:09 -08:00
clawbot fd77a047f9 security: zero plaintext after copying to memguard in DecryptWithIdentity
The decrypted data from io.ReadAll was copied into a memguard
LockedBuffer but the original byte slice was never zeroed, leaving
plaintext in swappable, dumpable heap memory.
2026-02-08 12:04:38 -08:00
clawbot 341428d9ca fix: NumSecrets() now correctly counts secrets by checking for current file
NumSecrets() previously looked for non-directory, non-'current' files
directly under each secret directory, but the only children are
'current' (file, excluded) and 'versions' (directory, excluded),
so it always returned 0.

Now checks for the existence of the 'current' file, which is the
canonical indicator that a secret exists and has an active version.

This fixes the safety check in UnlockersRemove that was always
allowing removal of the last unlocker.
2026-02-08 12:04:15 -08:00
sneak 128c53a11d Add cross-vault move command for secrets
Implement syntax: secret move/mv <vault>:<secret> <vault>[:<secret>]
- Copies all versions to destination vault with re-encryption
- Deletes source after successful copy (true move)
- Add --force flag to overwrite existing destination
- Support both within-vault rename and cross-vault move
- Add shell completion for vault:secret syntax
- Include integration tests for cross-vault move
2025-12-23 15:24:13 +07:00
sneak 7264026d66 Fix unlocker rm to succeed when keychain item is missing
When removing a keychain unlocker, if the keychain item doesn't exist
(e.g., already manually deleted or vault synced from another machine),
the removal should still succeed since the goal is to remove the
unlocker and the keychain item being gone already satisfies that goal.
2025-12-23 14:14:14 +07:00
sneak 20690ba652 Switch from relative paths to bare names in pointer files
- currentvault now contains just the vault name (e.g., "default")
- current-unlocker now contains just the unlocker name (e.g., "passphrase")
- current version file now contains just the version (e.g., "20231215.001")
- Resolution functions prepend the appropriate directory prefix
2025-12-23 13:43:10 +07:00
sneak 949a5aee61 Replace symlinks with plain files containing relative paths
- Remove all symlink creation and resolution in favor of plain files
- currentvault file now contains relative path like "vaults.d/default"
- current-unlocker file now contains relative path like "unlockers.d/passphrase"
- current version file now contains relative path like "versions/20231215.001"
- Simplify path resolution to just read file contents and join with parent dir
- Update all tests to read files instead of using os.Readlink
2025-12-23 11:53:28 +07:00
sneak 18fb79e971 Fix 'secret get' to output to stdout instead of stderr
- Add Print method to CLI Instance that uses cmd.OutOrStdout()
- Update GetSecretWithVersion to use cli.Print instead of cmd.Print
- Add test to verify secret values go to stdout, not stderr
- Store command reference in Instance for proper output handling
2025-07-29 20:01:10 +02:00
sneak b301a414cb README updates 2025-07-27 17:38:46 +02:00
sneak 92c41bdb0c Fix error handling in AddSecret to clean up on failure
- Clean up secret directory if Save() fails for new secrets
- Add tests to verify cleanup behavior
- Ensures failed secret additions don't leave orphaned directories
2025-07-26 22:03:31 +02:00
sneak 75c3d22b62 Fix vault creation to require mnemonic and set up initial unlocker
- Vault creation now prompts for mnemonic if not in environment
- Automatically creates passphrase unlocker during vault creation
- Prevents 'missing public key' error when adding secrets to new vaults
- Updates tests to reflect new vault creation flow
2025-07-26 21:58:57 +02:00
sneak a6f24e9581 Fix --keyid flag scope and implement secret move command
- Restrict --keyid flag to PGP unlocker type only
- Add validation to prevent --keyid usage with non-PGP unlockers
- Implement 'secret move' command with 'mv' and 'rename' aliases
- Add comprehensive tests for move functionality
- Update documentation to reflect optional nature of --keyid for PGP

The move command allows renaming or moving secrets within a vault while
preserving all versions and metadata. It fails if the destination already
exists to prevent accidental overwrites.
2025-07-26 01:26:27 +02:00
sneak a73a409fe4 Refactor unlockers command structure and add quiet flag to list command
- Rename 'unlockers' command to 'unlocker' for consistency
- Move all unlocker subcommands (list, add, remove) under single 'unlocker' command
- Add --quiet/-q flag to 'secret list' for scripting support
- Update documentation and tests to reflect command changes

The quiet flag outputs only secret names without headers or formatting,
making it ideal for shell script usage like: secret get $(secret list -q | head -1)
2025-07-22 16:04:44 +02:00
sneak 70d19d09d0 latest 2025-07-22 13:35:19 +02:00
sneak 40ea47b2a1 Add missing changes from feature branch
- Update Makefile to run lint and vet before tests
- Add install target to Makefile
- Fix keychainunlocker_stub.go for non-Darwin platforms
2025-07-22 12:51:02 +02:00
sneak 7ed3e287ea Merge branch 'add-list-remove-commands' 2025-07-22 12:47:20 +02:00
sneak 8e3530a510 Fix use-after-free crash in readSecurePassphrase
The function was using defer to destroy password buffers, which caused
the buffers to be freed before the function returned. This led to a
SIGBUS error when trying to access the destroyed buffer's memory.

Changed to manual memory management to ensure buffers are only destroyed
when no longer needed, and the first buffer is returned directly to the
caller who is responsible for destroying it.
2025-07-22 12:46:16 +02:00
sneak e5d7407c79 Fix mnemonic input to not echo to screen
Changed mnemonic input to use secure non-echoing input like passphrases:
- Use secret.ReadPassphrase() instead of readLineFromStdin()
- Add newline after hidden input for better UX
- Remove unused stdin reading functions from cli.go

This prevents sensitive mnemonic phrases from being displayed on screen
during input, matching the security behavior of passphrase input.
2025-07-22 12:39:32 +02:00
sneak 377b51f2db Add Docker support for building and running the CLI tool
- Add DOCKER_HOST export to Makefile for remote Docker daemon
- Create multi-stage Dockerfile:
  - Build stage: golang:1.24-alpine with gcc, make, git
  - Runtime stage: alpine with ca-certificates, gnupg
  - Runs as non-root 'secret' user
- Add Makefile targets:
  - docker: build container as sneak/secret
  - docker-run: run container interactively
- Add .dockerignore to exclude build artifacts but keep .git
  for potential linker flags

Container includes GPG support for PGP unlockers and runs on Linux,
making it suitable for cross-platform testing and deployment.
2025-07-21 22:13:19 +02:00
sneak a09fa89f30 Fix cross-platform build issues and security vulnerabilities
- Add build tags to keychain implementation files (Darwin-only)
- Create stub implementations for non-Darwin platforms that panic
- Conditionally show keychain support in help text based on platform
- Platform check in UnlockersAdd prevents keychain usage on non-Darwin
- Verified GPG operations already protected against command injection
  via validateGPGKeyID() and proper exec.Command argument passing
- Keychain operations use go-keychain library, no shell commands

The application now builds and runs on Linux/non-Darwin platforms with
keychain functionality properly isolated to macOS only.
2025-07-21 22:05:23 +02:00
sneak 7af1e6efa8 Improve PGP unlocker ergonomics
- Support 'secret unlockers add pgp [keyid]' positional argument syntax
- Automatically detect and use default GPG key when no key is specified
- Change PGP unlocker ID format from <keyid>-pgp to pgp-<keyid>
- Check if PGP key is already added before creating duplicate unlocker
- Add getDefaultGPGKey() that checks gpgconf first, then falls back to
  first secret key
- Export ResolveGPGKeyFingerprint() for use in CLI
- Add checkUnlockerExists() helper to verify unlocker IDs

The new behavior:
- 'secret unlockers add pgp' uses default GPG key
- 'secret unlockers add pgp KEYID' uses specified key
- 'secret unlockers add pgp --keyid=KEYID' also works
- Errors if key is already added or no default key exists
2025-07-21 18:57:58 +02:00
sneak 09b3a1fcdc Remove internal/macse package and fix all linter issues
- Remove internal/macse package (Secure Enclave experiment)
- Fix errcheck: handle keychain.DeleteItem error return
- Fix lll: break long lines in command descriptions
- Fix mnd: add nolint comment for cobra.ExactArgs(2)
- Fix nlreturn: add blank lines before return/break statements
- Fix revive: add nolint comment for KEYCHAIN_APP_IDENTIFIER constant
- Fix nestif: simplify UnlockersRemove by using new NumSecrets method
- Add NumSecrets() method to vault.Vault for counting secrets
- Update golangci.yml to exclude ALL_CAPS warning (attempted various
  configurations but settled on nolint comment)

All tests pass, code is formatted and linted.
2025-07-21 17:48:47 +02:00
sneak 816f53f819 Replace shell-based keychain implementation with keybase/go-keychain library
- Replaced exec.Command calls to /usr/bin/security with native keybase/go-keychain API
- Added comprehensive test suite for keychain operations
- Fixed binary data storage in tests using hex encoding
- Updated macse tests to skip with explanation about ADE requirements
- All tests passing with CGO_ENABLED=1
2025-07-21 15:58:41 +02:00
sneak bba1fb21e6 docs 2025-07-15 19:01:29 +02:00
sneak d4f557631b prototype secure enclave interface 2025-07-15 09:37:02 +02:00
sneak e53161188c Fix remaining memory security issues
- Fixed gpgDecryptDefault to return *memguard.LockedBuffer instead of []byte
- Updated GPGDecryptFunc signature and all implementations
- Confirmed getSecretValue already returns LockedBuffer (was fixed earlier)
- Improved passphrase string handling by removing intermediate variables
- Note: String conversion for passphrases is unavoidable due to age library API
- All GPG decrypted data is now immediately protected in memory
2025-07-15 09:08:51 +02:00
sneak ff17b9b107 Update TODO.md - DecryptWithPassphrase already fixed
- DecryptWithPassphrase was automatically fixed when we updated DecryptWithIdentity
- It now returns LockedBuffer since it calls DecryptWithIdentity internally
2025-07-15 09:04:59 +02:00
sneak 63cc06b93c Fix DecryptWithIdentity to return LockedBuffer
- Changed DecryptWithIdentity to return *memguard.LockedBuffer instead of []byte
- Updated all callers throughout the codebase to handle LockedBuffer
- This ensures decrypted data is protected in memory immediately after decryption
- Fixed all usages in vault, secret, version, and unlocker implementations
- Removed duplicate buffer creation and unnecessary memory clearing
2025-07-15 09:04:34 +02:00
sneak 8ec3fc877d Fix GetValue methods to return LockedBuffer internally
- Changed Secret.GetValue and Version.GetValue to return *memguard.LockedBuffer
- Updated all internal callers to handle LockedBuffer properly
- For backward compatibility, vault.GetSecret still returns []byte but makes a copy
- This ensures secret values are protected in memory during decryption
- Updated tests to handle LockedBuffer returns
- Fixed CLI getSecretValue to use LockedBuffer throughout
2025-07-15 08:59:23 +02:00
sneak 819902f385 Fix gpgEncryptDefault to accept LockedBuffer for data parameter
- Changed GPGEncryptFunc signature to accept *memguard.LockedBuffer instead of []byte
- Updated gpgEncryptDefault implementation to use LockedBuffer
- Updated all callers including tests to pass LockedBuffer
- This ensures GPG encryption data is protected in memory
- Fixed linter issue with line length
2025-07-15 08:46:33 +02:00
sneak 292564c6e7 Fix storeInKeychain to accept LockedBuffer for data parameter
- Changed storeInKeychain to accept *memguard.LockedBuffer instead of []byte
- Updated caller in CreateKeychainUnlocker to create LockedBuffer before storing
- This ensures keychain data is protected in memory before being stored
- Added proper buffer cleanup with defer Destroy()
2025-07-15 08:44:09 +02:00
sneak eef2332823 Fix EncryptWithPassphrase to accept LockedBuffer for data parameter
- Changed EncryptWithPassphrase to accept *memguard.LockedBuffer instead of []byte
- Updated all callers to pass LockedBuffer:
  - CreatePassphraseUnlocker in vault/unlockers.go
  - Keychain unlocker in keychainunlocker.go
  - Tests in passphrase_test.go
- Removed intermediate dataBuffer creation since data is now already protected
- This ensures sensitive data is protected in memory throughout encryption
2025-07-15 08:42:46 +02:00
sneak e82d428b05 Remove deprecated Secret.Save function
- Removed unused deprecated Save(value []byte, force bool) function
- This function accepted unprotected secret data which was a security issue
- All code now uses vault.AddSecret directly with LockedBuffer
- Updated TODO.md to reflect completion of this security fix
2025-07-15 08:40:35 +02:00
sneak 9cbe055791 fmt 2025-07-15 08:33:16 +02:00
sneak 7596049828 uses protected memory buffers now for all secrets in ram 2025-07-15 08:32:33 +02:00
sneak d3ca006886 Merge branch 'main' into fix-memory-security 2025-07-15 07:36:13 +02:00
sneak f91281e991 Merge branch 'fix-test-json-fields' 2025-07-15 07:35:58 +02:00
sneak 7c5e78db17 fix: update JSON fields from snake_case to camelCase and make tests quiet by default
- Update all JSON field references in tests from snake_case to camelCase
- Update vault list JSON output to use currentVault instead of current_vault
- Make integration tests quiet by default unless run with -v flag
- Fix tests that were using exec.Command to use in-process execution helpers
- Tests now only show debug output when explicitly requested or on failure
2025-07-15 07:35:48 +02:00
sneak 8e374b3d24 add test binaries to gitignore 2025-07-15 07:24:15 +02:00
sneak c9774e89e0 WIP: refactor to use memguard for secure memory handling
- Add memguard dependency
- Update ReadPassphrase to return LockedBuffer
- Update EncryptWithPassphrase/DecryptWithPassphrase to accept LockedBuffer
- Remove string wrapper functions
- Update all callers to create LockedBuffers at entry points
- Update interfaces and mock implementations
2025-07-15 07:23:58 +02:00
sneak f9938135c6 fix: resolve all remaining linter issues (staticcheck, tagliatelle, lll)
- Fix staticcheck QF1011: Remove explicit type declaration for io.Writer variables
- Fix tagliatelle: Change all JSON tags from snake_case to camelCase
  - created_at → createdAt
  - keychain_item_name → keychainItemName
  - age_public_key → agePublicKey
  - age_priv_key_passphrase → agePrivKeyPassphrase
  - encrypted_longterm_key → encryptedLongtermKey
  - derivation_index → derivationIndex
  - public_key_hash → publicKeyHash
  - mnemonic_family_hash → mnemonicFamilyHash
  - gpg_key_id → gpgKeyId
- Fix lll: Break long function signature line to stay under 120 character limit

All linter issues have been resolved. The codebase now passes all linter checks.
2025-07-15 06:33:25 +02:00
sneak 386a27c0b6 fix: resolve all revive linter issues
Added missing package comments:
- cmd/secret/main.go
- internal/cli/cli.go
- internal/secret/constants.go
- internal/vault/management.go
- pkg/bip85/bip85.go

Fixed comment format issues for exported items:
- EnvStateDir, EnvMnemonic, EnvUnlockPassphrase, EnvGPGKeyID in constants.go
- Metadata, UnlockerMetadata, SecretMetadata, Configuration in metadata.go
- AppBIP39, AppHDWIF, AppXPRV in bip85.go

Replaced unused parameters with underscore (_):
- generate.go:39 - parameter 'args'
- init.go:30 - parameter 'args'
- unlockers.go:39,77,102 - parameter 'args' or 'cmd'
- vault.go:37 - parameter 'args'
- management.go:34 - parameter 'target'
2025-07-15 06:06:48 +02:00
sneak 080a3dc253 fix: resolve all nlreturn linter errors
Add blank lines before return statements in all files to satisfy
the nlreturn linter. This improves code readability by providing
visual separation before return statements.

Changes made across 24 files:
- internal/cli/*.go
- internal/secret/*.go
- internal/vault/*.go
- pkg/agehd/agehd.go
- pkg/bip85/bip85.go

All 143 nlreturn issues have been resolved.
2025-07-15 06:00:32 +02:00
sneak 811ddee3b7 fix: resolve all nestif linter errors
- Extract getLongTermPrivateKey helper function to reduce nesting in keychainunlocker.go and pgpunlocker.go
- Add getPassphrase helper method to reduce nesting in passphraseunlocker.go
- Refactor version serial extraction to use early returns in version.go
- Extract resolveRelativeSymlink and tryResolveOsSymlink helpers in management.go
- Add processMnemonicForVault helper to reduce nesting in vault creation
- Extract resolveUnlockerDirectory and readUnlockerPathFromFile helpers in unlockers.go
- Add findUnlockerByID helper to reduce duplicate code in RemoveUnlocker and SelectUnlocker

All tests pass after refactoring.
2025-07-15 05:47:16 +02:00
sneak 4e242c3491 go 1.24 2025-07-09 16:09:59 -07:00
sneak 54fce0f187 fix: resolve mnd and nestif linter errors
- Define base85 constants (base85ChunkSize, base85DigitCount, base85Base)
- Replace magic numbers in base85 encoding logic with named constants
- Add nolint:nestif comments for legitimate nested conditionals:
  - crypto.go: Clear separation between secret generation vs retrieval
  - secrets.go: Separate JSON and table output formatting logic
  - vault.go: Separate JSON and text output formatting logic
2025-07-09 12:54:59 -07:00
sneak 93a32217e0 fix: resolve mnd (magic number) linter errors in agehd and bip85 packages
- Define x25519KeySize constant (32) at package level in agehd
- Replace all magic number 32 uses with x25519KeySize constant
- Define bech32BitSize8 and bech32BitSize5 constants for bit conversions
- Define bip85EntropySize constant (64) for entropy validation
- Define BIP39 word count constants (words12-24) with descriptive names
2025-07-09 12:52:46 -07:00
sneak 95ba80f618 fix: resolve gochecknoglobals, gosec, lll, and mnd linter errors
- Add nolint comments for BIP85 standard constants (MainNetPrivateKey, TestNetPrivateKey)
- Handle error return from shake.Write() in NewBIP85DRNG
- Fix line length issue by moving nolint comment to separate line
- Add nolint comment for cobra.ExactArgs(2) magic number
- Replace magic number 32 with named constant x25519KeySize in agehd package
2025-07-09 12:49:59 -07:00
sneak d710323bd0 fix: add nolint comments for necessary global variables in internal/secret
Add nolint:gochecknoglobals comments for legitimate global variables:
- debugEnabled and debugLogger: Package-wide debug state management
- GPGEncryptFunc and GPGDecryptFunc: Required for test mocking
- getCurrentVaultFunc: Required to break import cycle between packages
2025-07-09 12:47:51 -07:00
sneak 38b450cbcf fix: resolve mnd and nestif linter errors
- Added constants to replace magic numbers:
  - agePrivKeyPassphraseLength = 64
  - versionNameParts = 2
  - maxVersionsPerDay = 999
- Refactored crypto.go to reduce nesting complexity:
  - Inverted if condition to handle non-existent secret first
  - Extracted getSecretValue helper method
2025-07-09 07:05:07 -07:00
sneak 6fe49344e2 fix: resolve errcheck, gosec, and mnd linter errors
- Fixed unhandled errors in init.go (os.Setenv/Unsetenv)
- Fixed unhandled errors in test_helpers.go (os.Setenv/Unsetenv)
- Replaced magic numbers with named constants:
  - defaultSecretLength = 16
  - mnemonicEntropyBits = 128
  - tabWriterPadding = 2
2025-07-09 06:59:01 -07:00
sneak 6e01ae6002 chore: exclude errcheck linter from test files
Test files often ignore error returns for brevity and clarity,
especially for cleanup operations that don't affect test outcomes.
2025-07-09 06:18:52 -07:00
sneak 11e43542cf fix: handle error returns from os.Unsetenv and file.Close (errcheck)
Fixed the first five errcheck linter errors:
- Added error handling for os.Unsetenv in cli_test.go
- Added error handling for file.Close() in crypto.go (4 instances)
2025-07-09 06:16:13 -07:00
sneak 2256a37b72 Update golangci-lint configuration to v2.2.1 format
- Add version field set to "2" for golangci-lint v2.2.1
- Remove formatters (gofmt, gofumpt, goimports) from linters list
- Remove unsupported linters (gosimple, typecheck)
- Simplify usetesting configuration
- Move max-issues settings to proper location in issues section
- Remove timeout field from run section
2025-07-09 06:11:48 -07:00
sneak 533133486c fix: remove unnecessary type conversions (unconvert)
- Remove unnecessary conversions of UnlockerMetadata in vault/unlockers.go
- The metadata variable is already of the correct type due to type aliasing
2025-06-20 12:52:19 -07:00
sneak eb19fa4b97 fix: replace unused parameters with underscores (revive)
- Replace unused function parameters with _ in test files
- Affects version_test.go, debug_test.go, and pgpunlock_test.go
2025-06-20 12:50:16 -07:00
sneak 5ed850196b fix: convert ALL_CAPS constants to CamelCase (revive)
- Rename APP_BIP39 to AppBIP39
- Rename APP_HD_WIF to AppHDWIF
- Rename APP_XPRV to AppXPRV
- Rename APP_PWD85 to AppPWD85
- Update all references in the code
2025-06-20 12:49:01 -07:00
sneak be1f323a09 fix: remove unnecessary zero value initialization (revive)
- Remove explicit = 0 from uint32 declaration as it's the default zero value
2025-06-20 12:47:58 -07:00
sneak bdcddadf90 fix: resolve exported type stuttering issues (revive)
- Rename VaultMetadata to Metadata in internal/vault package to avoid stuttering
- Rename BIP85DRNG to DRNG in pkg/bip85 package to avoid stuttering
- Update all references in code and tests
2025-06-20 12:47:06 -07:00
sneak 4062242063 fix: break long error messages to meet line length limits
Split long error messages at logical points to comply with 120 character
line length limit while maintaining readability.
2025-06-20 09:51:26 -07:00
sneak abcc7b6c3a fix: resolve gosec integer overflow and unconvert issues
- Fix G115 integer overflow by converting uint32 to int comparison
- Remove unnecessary int() conversions for syscall constants
- syscall.Stdin/Stderr/Stdout are already int type
2025-06-20 09:50:00 -07:00
sneak 9e35bf21a3 fix: more nlreturn and testifylint issues
- Add blank lines before return statements
- Use require.Error instead of assert.Error for error assertions
- Keep exact float64 comparisons as-is (they are integers from JSON)
2025-06-20 09:40:17 -07:00
sneak 2a1e0337fd fix: add blank lines before return statements (nlreturn)
Fix nlreturn linting issues by adding blank lines before return
statements in cli and secret packages.
2025-06-20 09:37:56 -07:00
sneak dcc15008cd add instructions to keep going 2025-06-20 09:37:01 -07:00
sneak dd2e95f8af fix: replace magic file permissions and add crypto constant comments
Replace hardcoded 0o600 with secret.FilePerms constant for consistency.
Add explanatory comments for cryptographic constants (32-byte keys,
bech32 encoding parameters) rather than extracting them as they are
well-known cryptographic standard values.
2025-06-20 09:23:50 -07:00
sneak c450e1c13d fix: replace remaining os.Setenv with t.Setenv in tests
Replace all os.Setenv calls with t.Setenv in test functions to ensure
proper test environment cleanup and better test isolation. This leaves
only legitimate application code and helper functions using os.Setenv.
2025-06-20 09:22:01 -07:00
sneak c6935d8f0f add rules for claude 2025-06-20 09:20:52 -07:00
sneak 5d973f76ec fix: break long lines to 77 characters in non-test files
Break long lines in function signatures and strings to comply with
77 character preference by using multi-line formatting and extracting
variables where appropriate.
2025-06-20 09:17:45 -07:00
sneak fd125c5fe1 fix: disable line length checks for test files with test vectors
Add nolint:lll directives to test files containing long test vectors
and function signatures to avoid unnecessary line breaking.
2025-06-20 09:16:40 -07:00
sneak 08a42b16dd fix: replace os.Setenv with t.Setenv in tests (usetesting)
Replace os.Setenv calls with t.Setenv in test functions to ensure
proper test environment cleanup and better test isolation.
2025-06-20 09:13:01 -07:00
sneak b736789ecb fix: adjust line lengths to 77 characters
Break long lines in function signatures and strings to comply with
77 character preference for better readability.
2025-06-20 09:10:28 -07:00
sneakandClaude f569bc55ea fix: convert for loops to Go 1.22+ integer range syntax (intrange)
Convert traditional for loops to use the new Go 1.22+ integer range syntax:
- for i := 0; i < n; i++ → for i := range n (when index is used)
- for i := 0; i < n; i++ → for range n (when index is not used)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-20 09:05:49 -07:00
sneakandClaude 9231409c5c fix: remove unnecessary string conversions (unconvert)
Remove redundant string() conversions on output variables that are
already strings in test assertions and logging.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-20 09:02:56 -07:00
sneakandClaude 0d140b4636 fix: correct file permissions in integration test (gosec G306)
Change WriteFile permissions from 0o644 to 0o600 to address security
linting issue about file permissions being too permissive.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-20 09:02:01 -07:00
sneak 9e74b34b5d Fix remaining usetesting errors in vault integration test
Replace os.MkdirTemp() with t.TempDir() and os.Setenv() with t.Setenv()
Remove manual environment cleanup as t.Setenv handles it automatically
2025-06-20 08:58:29 -07:00
sneak 47afe117f4 Fix unused parameter errors in agehd and bip85 tests
- Remove unused goroutineID parameter from agehd concurrent test
- Remove unused description parameter from bip85 logTestVector function
- Update all call sites to match new signatures
2025-06-20 08:55:42 -07:00
sneak 4fe49ca8d0 Fix unused parameter errors in secret test mock implementations
Rename unused force and passphrase parameters to _ in MockVault
interface implementations as they are required by the interface
2025-06-20 08:52:19 -07:00
sneak 8ca7796d04 Fix unused parameter errors in debug.go slog.Handler interface
Rename unused parameters to _ in WithAttrs and WithGroup methods
as these are required by the slog.Handler interface
2025-06-20 08:51:13 -07:00
sneak dcab84249f Fix unused parameter errors in CLI integration tests
Remove unused tempDir parameter from test11ListSecrets and test15VaultIsolation
Remove unused runSecretWithStdin parameter from test17ImportFromFile
Update call sites to match new signatures
2025-06-20 08:50:34 -07:00
sneak e5b18202f3 Fix revive package stuttering errors
- Rename SecretMetadata to Metadata in secret package
- Rename SecretVersion to Version in secret package
- Update NewSecretVersion to NewVersion function
- Update all references across the codebase including:
  - vault package aliases
  - CLI usage
  - test files
  - method receivers and signatures
2025-06-20 08:48:17 -07:00
sneak efc9456948 Fix G115 integer overflow warnings in agehd tests
Add bounds checking before converting int to uint32 to prevent
potential integer overflow in benchmark and concurrent test functions
2025-06-20 08:27:41 -07:00
sneak c52430554a Fix usetesting errors in CLI integration test
Replace os.Setenv() with t.Setenv() for GODEBUG and SB_SECRET_STATE_DIR
environment variables in TestSecretManagerIntegration and test23ErrorHandling
2025-06-20 08:24:54 -07:00
sneak fd7ab06fb1 Modify test target to re-run in verbose mode only on failure 2025-06-20 08:12:06 -07:00
sneak 434b73d834 Fix intrange and G101 linting issues
- Convert for loops to use Go 1.22+ integer ranges in generate.go and helpers.go
- Disable G101 false positives for test vectors and environment variable names
- Add file-level gosec disable for bip85_test.go containing BIP85 test vectors
- Add targeted nolint comments for legitimate test data and constants
2025-06-20 08:08:01 -07:00
sneak 985d79d3c0 fix: resolve critical security vulnerabilities in debug logging and command execution
- Remove sensitive data from debug logs (vault/secrets.go, secret/version.go)
- Add input validation for GPG key IDs and keychain item names
- Resolve GPG key IDs to full fingerprints before storing in metadata
- Add comprehensive test coverage for validation functions
- Add golangci-lint configuration with additional linters

Security improvements:
- Debug logs no longer expose decrypted secret values or private keys
- GPG and keychain commands now validate input to prevent injection attacks
- All validation uses precompiled regex patterns for performance
2025-06-20 07:50:26 -07:00
sneak 004dce5472 passes tests now! 2025-06-20 07:24:48 -07:00
sneak 0b31fba663 latest from ai, it broke the tests 2025-06-20 05:40:20 -07:00
sneak 6958b2a6e2 ignore *.log files 2025-06-11 15:29:20 -07:00
sneak fd4194503c removed file erroneously committed 2025-06-11 15:29:02 -07:00
sneak a1800a8e88 removed binary erroneously committed by LLM :/ 2025-06-11 15:28:14 -07:00
sneak 03e0ee2f95 refactor: remove confusing dual ID method pattern from Unlocker interface - Removed redundant ID() method from Unlocker interface - Removed ID field from UnlockerMetadata struct - Modified GetID() to generate IDs dynamically based on unlocker type and data - Updated vault package to create unlocker instances when searching by ID - Fixed all tests and CLI code to remove ID field references - IDs are now consistently generated from unlocker data, preventing redundancy 2025-06-11 15:21:20 -07:00
sneak 9adf0c0803 refactor: fix redundant metadata fields across the codebase - Removed VaultMetadata.Name (redundant with directory structure) - Removed SecretMetadata.Name (redundant with Secret.Name field) - Removed AgePublicKey and AgeRecipient from PGPUnlockerMetadata - Removed AgePublicKey from KeychainUnlockerMetadata - Changed PGP and Keychain unlockers to store recipient in pub.txt instead of pub.age - Fixed all tests to reflect these changes - Follows DRY principle and prevents data inconsistency 2025-06-09 17:44:10 -07:00
sneak e9d03987f9 refactor: remove redundant SecretName and Version fields from VersionMetadata - Removed SecretName and Version fields that were redundant with directory structure and parent SecretVersion struct - Updated tests to remove references to deleted fields - Follows DRY principle and prevents potential data inconsistency 2025-06-09 17:26:57 -07:00
sneak b0e3cdd3d0 fix: Restore fmt target to Makefile 2025-06-09 17:22:44 -07:00
sneak 2e3fc475cf fix: Use vault metadata derivation index for environment mnemonic - Fixed bug where GetValue() used hardcoded index 0 instead of vault metadata - Added test31 to verify environment mnemonic respects vault derivation index - Rewrote test19DisasterRecovery to actually test manual recovery process - Removed all test skip statements as requested 2025-06-09 17:21:02 -07:00
sneak 1f89fce21b latest 2025-06-09 05:59:26 -07:00
sneak 512b742c46 latest agent instructions 2025-06-09 05:59:17 -07:00
sneak 02be4b2a55 Fix integration tests: correct vault derivation index and debug test failures 2025-06-09 04:54:45 -07:00
sneak e036d280c0 tests pass now, not sure if they are any good 2025-06-08 22:29:55 -07:00
sneak ac81023ea0 add LLM instructions 2025-06-08 22:19:13 -07:00
sneak d76a4cbf4d fix tests 2025-06-08 22:13:22 -07:00
sneak fbda2d91af add secret versioning support 2025-06-08 22:07:19 -07:00
sneak f59ee4d2d6 'unlock keys' renamed to 'unlockers' 2025-05-30 07:29:02 -07:00
sneak 0bf8e71b52 fix: resolve ineffectual assignment lint error in pgpunlock_test.go 2025-05-29 16:36:10 -07:00
sneak 34d6870e6a feat: add derivation index to vault metadata for unique keys - Add VaultMetadata fields: DerivationIndex, LongTermKeyHash, MnemonicHash - Implement GetNextDerivationIndex() to track and increment indices for same mnemonics - Update init and import commands to use proper derivation indices - Add ComputeDoubleSHA256() for hash calculations - Save vault metadata on creation with all derivation information - Add comprehensive tests for metadata functionality. This ensures multiple vaults using the same mnemonic will derive different long-term keys by using incremented derivation indices. The mnemonic is double SHA256 hashed and stored to track which vaults share mnemonics. Fixes TODO item #5 2025-05-29 16:23:29 -07:00
sneak 1a1b11c5a3 Add comprehensive PGP unlock key testing with non-interactive GPG support 2025-05-29 15:05:58 -07:00
sneak 85d7ef21eb Add comprehensive test coverage and fix empty branch issue 2025-05-29 14:18:39 -07:00
sneak a4d7225036 Standardize file permissions using constants and fix parameter ordering inconsistencies 2025-05-29 13:13:44 -07:00
sneak 8dc2e9d748 Remove duplicated wrapper crypto functions and use exported implementations directly 2025-05-29 13:08:00 -07:00
sneak 8cc15fde3d latest 2025-05-29 13:02:39 -07:00
sneak ddb395901b Refactor vault functionality to dedicated package, fix import cycles with interface pattern, fix tests 2025-05-29 12:48:36 -07:00
sneak c33385be6c Clean up integration test script: remove redundant tests and fix misleading output - Remove redundant manual input tests that were actually using environment variables - Update all test output to honestly reflect automated testing with env vars - Consolidate similar test cases to reduce duplication - Fix cross-vault operations test by properly recreating work vault after reset_state - Import mnemonic into work vault so it can store secrets - Update test descriptions to be accurate about automation vs manual input - All tests now pass successfully with proper environment variable usage 2025-05-29 11:04:31 -07:00
sneak e95609ce69 latest 2025-05-29 11:02:22 -07:00
sneak 345709a306 refactor: Implement proper separation between unlock keys and secret decryption - Remove DecryptSecret methods from all unlock key implementations - Secrets now handle their own decryption via Secret.GetValue(unlockKey) - Unlock keys are only responsible for vault access (getting long-term key) - Add decryptWithLongTermKey helper for per-secret key architecture - Fix vault import to work in non-interactive mode without unlock keys - Maintain clean architecture: unlock keys → vault access → secret decryption - All tests passing with new architecture 2025-05-29 10:06:30 -07:00
sneak 4b59d6fb82 fix: Update integration test script for new architecture - Update file checks to expect value.age instead of secret.age - Add debug output support with GODEBUG environment variable - Remove output redirections to show command execution and debug info - Fix test expectations to match per-secret key file structure 2025-05-29 09:52:39 -07:00
sneak 5ca657c104 feat: Enhance debug logging system - Add TTY detection for colorized vs JSON output - Disable stderr buffering when debug is enabled for immediate output - Add comprehensive debug functions with structured logging support - Improve debugging experience during development and troubleshooting 2025-05-29 09:52:32 -07:00
sneak bbaf1cbd97 fix: Prevent hanging in non-interactive environments - Add terminal detection to readPassphrase, readSecurePassphrase, and readLineFromStdin - Return clear error messages when stderr is not a terminal instead of hanging - Improves automation and CI/CD reliability 2025-05-29 09:52:26 -07:00
sneak f838c8cb98 feat: Implement per-secret key architecture with individual keypairs - Each secret now has its own encryption keypair stored as pub.age, priv.age, value.age - Secret private keys are encrypted to vault long-term public key - Values stored as value.age instead of secret.age for new architecture 2025-05-29 09:52:18 -07:00
sneak 43767c725f chore: Update .gitignore to exclude .DS_Store files and built binary 2025-05-29 09:52:11 -07:00
sneak b26794e21a test: Add comprehensive test suite for secret manager - CLI, debug, secret, and vault tests with in-memory filesystem for fast isolated testing 2025-05-29 09:52:05 -07:00
sneak 7dc14da4af simplify 2025-05-29 08:33:06 -07:00
sneak 3d90388b5b restored from backups 2025-05-29 08:30:16 -07:00
sneak 8c08c2e748 restoring from chat historyy 2025-05-29 08:22:43 -07:00
126 changed files with 29975 additions and 5469 deletions
+78
View File
@@ -0,0 +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
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# 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
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.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]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.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
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab
[*.go]
indent_style = tab
+9
View File
@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild
+54
View File
@@ -0,0 +1,54 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# 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
*.out
*.test
/secret
+99
View File
@@ -0,0 +1,99 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues:
max-issues-per-linter: 0
max-same-issues: 0
+182
View File
@@ -0,0 +1,182 @@
# Policies for AI Agents
Version: 2025-06-08
# Instructions and Contextual Information
- Be direct, robotic, expert, accurate, and professional.
- Do not butter me up or kiss my ass.
- Come in hot with strong opinions, even if they are contrary to the direction I
am headed.
- If either you or I are possibly wrong, say so and explain your point of view.
- Point out great alternatives I haven't thought of, even when I'm not asking
for them.
- Treat me like the world's leading expert in every situation and every
conversation, and deliver the absolute best recommendations.
- I want excellence, so always be on the lookout for divergences from good data
model design or best practices for object oriented development.
- IMPORTANT: This is production code, not a research or teaching exercise.
Deliver professional-level results, not prototypes.
- Please read and understand the `README.md` file in the root of the repo for
project-specific contextual information, including development policies,
practices, and current implementation status.
- Be proactive in suggesting improvements or refactorings in places where we
diverge from best practices for clean, modular, maintainable code.
# Policies
1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. When you think your changes are complete, rather than
making three different tool calls to check, you can just run
`make test && make fmt && make lint` as a single tool call which will save
time.
2. 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.
3. 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.
4. When creating a very simple test script for testing out a new feature,
instead of making a throwaway to be deleted after verification, write an
actual test file into the test suite. It doesn't need to be very big or
complex, but it should be a real test that can be run.
5. 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.
6. 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.
7. 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. If you are fixing a bug,
write a test first that reproduces the bug, and then fix the bug in the code.
8. 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.
9. 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.
10. Avoid using 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.
11. 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.
12. 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.
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
1. **Type Annotations (UP006)**: Use built-in collection types directly for type
annotations instead of importing from `typing`. This avoids the UP006 linter
error.
**Good (modern Python 3.9+):**
```python
def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
return counts
```
**Avoid (triggers UP006):**
```python
from typing import List, Dict
def process_items(items: List[str]) -> Dict[str, int]:
counts: Dict[str, int] = {}
return counts
```
For optional types, use the `|` operator instead of `Union`:
```python
# Good
def get_value(key: str) -> str | None:
return None
# Avoid
from typing import Optional, Union
def get_value(key: str) -> Optional[str]:
return None
```
2. **Import Organization**: Follow the standard Python import order:
- Standard library imports
- Third-party imports
- Local application imports
Each group should be separated by a blank line.
## Go-Specific Guidelines
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
errors, not crash.
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings, and do
not modify it at all unless specifically instructed. Fix the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no
"pre-existing issue" excuses.
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`.**
+79
View File
@@ -0,0 +1,79 @@
# Lint phase. The linter is invoked directly rather than through `make
# lint` or `script/lint`, which are themselves a docker build.
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go vet ./...
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.
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.24.13-trixie, 2026-02-04
FROM golang@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS test
ENV CGO_ENABLED=1
WORKDIR /src
COPY go.mod go.sum ./
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; }
# Build stage. Nothing is wanted from either phase above; the copies are
# 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 . .
# 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; \
make build VERSION="${VERSION:-dev}"
# Runtime stage, and the last one
# alpine 3.23, 2026-03-10
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates gnupg
RUN adduser -D -s /bin/sh secret
COPY --from=builder /src/secret /usr/local/bin/secret
RUN chmod +x /usr/local/bin/secret
USER secret
WORKDIR /home/secret
ENTRYPOINT ["secret"]
+33 -76
View File
@@ -1,93 +1,50 @@
# Makefile for Secret Manager - Simple Go CLI Tool
export CGO_ENABLED=1
# Configuration
BINARY_NAME = secret
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
docker-run clean install hooks
default: build
default: check
# Simple build (no code signing needed)
build: clean
@echo "Building secret manager..."
go build -o $(BINARY_NAME) cmd/secret/main.go
@echo "Build complete: ./$(BINARY_NAME)"
bootstrap:
@script/bootstrap
# Build with verbose output
build-verbose: clean
@echo "Building with verbose output..."
go build -v -o $(BINARY_NAME) cmd/secret/main.go
@echo "Build complete: ./$(BINARY_NAME)"
setup:
@script/setup
# Vet the code
vet:
@echo "Running go vet..."
go vet ./...
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
build:
@script/build
# Test with linting and vetting
test: vet lint
@echo "Running go tests..."
go test -v ./...
test:
@script/test
# Run comprehensive test script
test-comprehensive: build
@echo "Running comprehensive test script..."
@chmod +x test_secret_manager.sh
@./test_secret_manager.sh
fmt:
@script/fmt
# Run all tests (unit tests + comprehensive tests)
test-all: test test-comprehensive
# Lint the code
# Vets and lints the Linux build, then the macOS build
lint:
@echo "Running linter..."
golangci-lint run --timeout 5m
@script/lint
# Check all code quality (build + vet + lint + unit tests)
check: build vet lint test
check:
@script/check
# Build Docker container
docker:
@script/docker
# Run Docker container interactively
docker-run:
docker run --rm -it "$$(./script/projectname)"
# Clean build artifacts
clean:
rm -f ./$(BINARY_NAME)
rm -f ./secret
# Install to /usr/local/bin
install: build
@echo "Installing to /usr/local/bin..."
sudo cp $(BINARY_NAME) /usr/local/bin/
@echo "Installed to /usr/local/bin/$(BINARY_NAME)"
cp ./secret $(HOME)/bin/secret
# Uninstall from /usr/local/bin
uninstall:
@echo "Removing from /usr/local/bin..."
sudo rm -f /usr/local/bin/$(BINARY_NAME)
@echo "Uninstalled $(BINARY_NAME)"
fmt-check:
@script/fmt-check
# Test keychain functionality
test-keychain:
@echo "Testing keychain functionality..."
@./$(BINARY_NAME) --help > /dev/null 2>&1 && echo "Binary runs successfully" || echo "Binary failed to run"
# Help target
help:
@echo "Secret Manager - Simple Go CLI Tool"
@echo "===================================="
@echo ""
@echo "Available targets:"
@echo " build - Build the secret manager (default)"
@echo " build-verbose - Build with verbose output"
@echo " vet - Run go vet"
@echo " lint - Run linter only"
@echo " test - Run unit tests with vet and lint"
@echo " test-comprehensive - Run comprehensive test script"
@echo " test-all - Run both unit tests and comprehensive tests"
@echo " check - Run all code quality checks"
@echo " clean - Remove build artifacts"
@echo " install - Install to /usr/local/bin"
@echo " uninstall - Remove from /usr/local/bin"
@echo " test-keychain - Test basic functionality"
@echo " help - Show this help"
@echo ""
@echo "Usage:"
@echo " make build && ./secret --help"
@echo " make test-all # Run all tests"
@echo " make check # Run all quality checks"
.PHONY: default build build-verbose vet test test-comprehensive test-all lint check clean install uninstall test-keychain help
hooks:
@script/install-precommit
+423 -126
View File
@@ -1,226 +1,419 @@
# Secret - Hierarchical Secret Manager
# secret - Local Secret Manager
Secret is a modern, secure command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
## Description
## Core Architecture
`secret` is a WTFPL-licensed Go command-line local secret manager by
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture
for storing and managing sensitive data. It supports multiple vaults, various
unlock mechanisms, and provides secure storage using the `age` encryption
library.
## Getting Started
Build from source, then install the binary as `~/bin/secret`:
```bash
git clone https://git.eeqj.de/sneak/secret.git
cd secret
make build # writes the binary to ./secret
make install # builds it and copies it to ~/bin/secret
```
Generate a mnemonic, create the default vault, then store and read a secret:
```bash
secret generate mnemonic # prints a new BIP39 mnemonic; write it down
secret init # asks for that mnemonic and an unlocker passphrase
echo "my-password" | secret add myservice/password
secret get myservice/password
```
## Rationale
I created `secret` to scratch an itch: I wanted a secure key/value store to
replace a bunch of PGP-encrypted files in a directory structure. It could be
used as a password manager, but was not designed as one.
## Design
### Three-Layer Key Hierarchy
Secret implements a sophisticated three-layer key architecture:
Secret implements a three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
2. **Unlock Keys**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
foundation for all encryption
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
multiple authentication methods
3. **Version-specific Keys**: Per-version keys that encrypt individual secret
values
### Version Management
Each secret maintains a history of versions, with each version having:
- Its own encryption key pair
- Metadata including creation time and validity period, encrypted to the
version's key pair
- Immutable value storage
The secret's `current` file names its current version. Switching versions
replaces that file in one rename, so it is never half-written.
### Vault System
Vaults provide logical separation of secrets, each with its own long-term key and unlock key set. This allows for complete isolation between different contexts (work, personal, projects).
## Installation
Build from source:
```bash
git clone <repository>
cd secret
go build -o secret ./cmd/secret
```
## Quick Start
1. **Initialize the secret manager**:
```bash
secret init
```
This creates the default vault and prompts for a BIP39 mnemonic phrase.
2. **Generate a mnemonic** (if needed):
```bash
secret generate mnemonic
```
3. **Add a secret**:
```bash
echo "my-password" | secret add myservice/password
```
4. **Retrieve a secret**:
```bash
secret get myservice/password
```
Vaults provide logical separation of secrets, each with its own long-term key
and unlocker set. This allows for complete isolation between different contexts
(work, personal, projects).
## Commands Reference
### Confirmation Before Removal
`secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
removes nothing. The question is asked only after the command's checks have
passed, and before it changes anything.
Whether to ask is decided by stdin, where the answer is read from, so
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
script or a CI job, nobody is there to answer: the command fails at once,
removes nothing, and says to pass `--force`.
`--force` (`-f`) removes without asking, whatever the command removes: a vault
that holds secrets and the last unlocker of a vault included. Scripts that
remove things pass `--force`.
### Initialization
#### `secret init`
Initializes the secret manager with a default vault. Prompts for a BIP39 mnemonic phrase and creates the initial directory structure.
Initializes the secret manager with a default vault. Prompts for a BIP39
mnemonic phrase and creates the initial directory structure.
**Environment Variables:**
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
### Vault Management
#### `secret vault list [--json]`
Lists all available vaults.
#### `secret vault list [--json]` / `secret vault ls`
Lists all available vaults. The current vault is marked.
#### `secret vault create <name>`
Creates a new vault with the specified name.
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
are allowed, and a name must not be empty, `.` or `..`.
#### `secret vault select <name>`
Switches to the specified vault for subsequent operations.
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
**DANGER**: Permanently removes a vault and all its secrets. It first asks for
confirmation, naming the vault and how many secrets it holds (see
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
cannot be removed. Removing the current vault makes another vault the current
one.
- `--force, -f`: Remove without asking, also a vault that contains secrets
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
### Secret Management
#### `secret add <secret-name> [--force]`
Adds a secret to the current vault. Reads the secret value from stdin.
- `--force, -f`: Overwrite existing secret
**Secret Name Format:** `[a-z0-9\.\-\_\/]+`
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
contain `//`, or have `..` as a path segment.
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
- Examples: `database/password`, `api.key`, `ssh_private_key`
#### `secret get <secret-name>`
#### `secret get <secret-name> [--version <version>]`
Retrieves and outputs a secret value to stdout.
- `--version, -v`: Get a specific version (default: current)
#### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring matching.
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
confirmation, naming the secret, its vault and how many versions it has (see
[Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, the secret cannot be recovered
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently
deleted
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
Moves or renames a secret within the current vault.
- Fails if the destination already exists
- Fails if the destination is the source under another name, such as `foo` for
`Foo` on a case-insensitive filesystem (the macOS default); there, to change
only the case of a name, move the secret to a third name first
- Preserves all versions and metadata
### Version Management
#### `secret version list <secret-name>` / `secret version ls`
Lists all versions of a secret showing creation time, status, and validity
period.
#### `secret version promote <secret-name> <version>`
Promotes a specific version to current by rewriting the secret's `current` file
to name it. Does not modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. It first asks
for confirmation, naming the version, the secret and its vault (see
[Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first)
### Key Generation
#### `secret generate mnemonic`
Generates a cryptographically secure BIP39 mnemonic phrase.
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
stdout. The warning to keep it safe goes to stderr.
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
Generates and stores a random secret.
- `--length, -l`: Length of generated secret (default: 16)
- `--type, -t`: Type of secret (`base58`, `alnum`)
- `--force, -f`: Overwrite existing secret
### Unlock Key Management
### Unlocker Management
#### `secret keys list [--json]`
Lists all unlock keys in the current vault with their metadata.
#### `secret unlocker list [--json]` / `secret unlocker ls`
#### `secret keys add <type> [options]`
Creates a new unlock key of the specified type:
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]`
Creates a new unlocker of the specified type:
**Types:**
- `passphrase`: Traditional passphrase-protected unlock key
- `keychain`: macOS Keychain-protected unlock key (macOS only)
- `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption
**Options:**
- `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret keys rm <key-id>`
Removes an unlock key.
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
specified)
#### `secret key select <key-id>`
Selects an unlock key as the current default for operations.
A vault has one passphrase unlocker: adding one replaces the one the vault has,
which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
naming the unlocker and its vault and saying whether it is the vault's last
unlocker; for the last one it says how many secrets the vault holds and warns
that the vault then opens only with its mnemonic (see
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
directory that `secret unlocker list` skips with a warning, because its metadata
cannot be read or parsed, is removed by the directory name the warning gives.
Removing the current unlocker makes the first of the vault's other unlockers
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
them, the current one, and says which; when there is none, the vault is left
without a current unlocker. `secret unlocker select` refuses an unlocker whose
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
that does not exist and the vault has another that `secret unlocker select`
accepts, opening the vault fails with an error that says to select one with
`secret unlocker select`.
- `--force, -f`: Remove without asking, even the last unlocker
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
losing access to all secrets forever
#### `secret unlocker select <unlocker-id>`
Selects an unlocker as the current default for operations.
### Import Operations
#### `secret import <secret-name> --source <filename>`
Imports a secret from a file and stores it in the current vault under the given name.
Imports a secret from a file and stores it in the current vault under the given
name.
#### `secret vault import [vault-name]`
Imports a mnemonic phrase into the specified vault (defaults to "default").
### Encryption Operations
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
generates a new Age key.
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
Decrypts data using an Age key stored as a secret.
## Storage Architecture
### 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/
│ ├── default/
│ │ ├── unlock-keys.d/
│ │ │ ├── passphrase/ # Passphrase unlock key
│ │ │ ├── keychain/ # Keychain unlock key (macOS)
│ │ │ └── pgp/ # PGP unlock key
│ │ ├── unlockers.d/
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/
│ │ │ │ │ ├── 20231215.001/ # Version directory
│ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current # Current version's name: 20231216.001
│ │ │ └── database%password/ # Secret: database/password
│ │ └── current-unlock-key -> ../unlock-keys.d/passphrase
│ │ │ ├── versions/
│ │ │ └── current # Current version's name: 20231215.001
│ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker # Current unlocker's directory name
│ └── work/
│ ├── unlock-keys.d/
│ ├── unlockers.d/
│ ├── secrets.d/
│ └── current-unlock-key
├── currentvault -> vaults.d/default
└── configuration.json
│ ├── vault-metadata.json
│ ├── pub.age
│ └── current-unlocker
├── 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
#### Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
#### 1: Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
deterministic (HD) key derivation
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlock keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by
unlockers
#### Unlock Keys
Unlock keys provide different authentication methods to access the long-term keys:
#### 2: Unlockers
1. **Passphrase Keys**:
- Encrypted with user-provided passphrase
- Stored as encrypted Age keys
- Cross-platform compatible
Unlockers provide different authentication methods to access the long-term keys:
2. **Keychain Keys** (macOS only):
- Uses macOS Keychain for secure storage
- Provides seamless authentication on macOS systems
- Age private key encrypted with random passphrase stored in Keychain
1. **Passphrase Unlockers**:
- Encrypted with user-provided passphrase
- Stored as encrypted Age keys
- Cross-platform compatible
3. **PGP Keys**:
- Uses existing GPG key infrastructure
- Leverages existing key management workflows
- Strong authentication through GPG
2. **PGP Unlockers**:
- Uses existing GPG key infrastructure
- Leverages existing key management workflows
- Strong authentication through GPG
Each vault maintains its own set of unlock keys and one long-term key. The long-term key is encrypted to each unlock key, allowing any authorized unlock key to access vault secrets.
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
access vault secrets.
#### Secret-specific Keys
- Each secret has its own encryption key pair
#### 3: Secret-specific Keys
- Each secret version has its own encryption key pair
- 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
- `SB_SECRET_STATE_DIR`: Custom state directory location
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase (avoids interactive prompt)
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlock keys
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
they hold. Other processes running as the same user can read a process's
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
shell or script that sets them inherits them, `gpg` included. Set on a command
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
each one as soon as it has read it, so that the programs it runs itself, such as
`gpg`, do not inherit it, but that erases nothing: the environment the process
started with, and its memory, still hold the value. The interactive prompt,
which every command except `secret vault import` offers when the variable is not
set, is the safer default; `secret vault import` has no prompt and needs both
variables.
The prompt asks on the terminal the command runs in, not on stdin, so it also
asks when stdin is a pipe, as in `echo value | secret add name --force` or
`cat file | secret encrypt key`. With no controlling terminal, as under cron or
in a CI job, the command fails and names the variable to set.
## Security Features
### Encryption
- Uses the [Age encryption library](https://age-encryption.org/) with X25519 keys
- Uses the [age encryption library](https://age-encryption.org/) with X25519
keys
- All private keys are encrypted at rest
- No plaintext secrets stored on disk
### Access Control
- Multiple authentication methods supported
- Hierarchical key architecture provides defense in depth
- Vault isolation prevents cross-contamination
### Forward Secrecy
- Per-secret encryption keys limit exposure if compromised
- Long-term keys protected by multiple unlock key layers
### Platform Integration
- macOS Keychain integration for seamless authentication
- GPG integration for existing key management workflows
- Per-version encryption keys limit exposure if compromised
- Each version is independently encrypted
- Historical versions remain encrypted with their original keys
### Hardware Integration
- Hardware token support via PGP/GPG integration
## Examples
### Basic Workflow
```bash
# Initialize with a new mnemonic
secret generate mnemonic # Copy the output
@@ -235,9 +428,13 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
secret list
secret get database/prod/password
secret get services/api/key
# Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
secret remove ssh/servers/web01
```
### Multi-vault Setup
```bash
# Create separate vaults for different contexts
secret vault create work
@@ -246,7 +443,7 @@ secret vault create personal
# Work with work vault
secret vault select work
echo "work-db-pass" | secret add database/password
secret keys add passphrase # Add passphrase authentication
secret unlocker add passphrase # Add passphrase authentication
# Switch to personal vault
secret vault select personal
@@ -254,23 +451,43 @@ echo "personal-email-pass" | secret add email/password
# List all vaults
secret vault list
# Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force
```
### Advanced Authentication
```bash
# Add multiple unlock methods
secret keys add passphrase # Password-based
secret keys add keychain # macOS Keychain (macOS only)
secret keys add pgp --keyid ABCD1234 # GPG key
secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key
# List unlock keys
secret keys list
# List unlockers
secret unlocker list
# Select a specific unlock key
secret key select <key-id>
# Select a specific unlocker
secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (asks first!)
secret unlocker remove <unlocker-id>
```
### Version Management
```bash
# List all versions of a secret
secret version list database/prod/password
# Promote an older version to current
secret version promote database/prod/password 20231215.001
# Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
secret version remove database/prod/password 20231214.001
```
### Encryption/Decryption with Age Keys
```bash
# Generate an Age key and store it as a secret
secret generate secret encryption/mykey
@@ -285,61 +502,141 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
## Technical Details
### Cryptographic Primitives
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
- **Authentication**: Poly1305 MAC
- **Hashing**: Double SHA-256 for public key identification
### File Formats
- **Age Files**: Standard Age encryption format (.age extension)
- **Metadata**: JSON format with timestamps and type information
- **Configuration**: JSON configuration files
- **age Files**: Standard age encryption format (.age extension), except
`pub.age`, which holds an age public key as text
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
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
- **Derivation Index**: Each vault uses a unique derivation index from the
mnemonic, and thus a unique key pair
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
hash of the index-0 public key identifies vaults from the same mnemonic
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
automatically derived
### Cross-Platform Support
- **macOS**: Full support including Keychain integration
- **Linux**: Full support (excluding Keychain features)
- **Windows**: Basic support (filesystem operations only)
- **macOS**: Full support
- **Linux**: Full support
## Security Considerations
### Threat Model
- Protects against unauthorized access to secret values
- Provides defense against compromise of individual components
- Supports platform-specific authentication where available
- Supports hardware-backed authentication where available
### Best Practices
1. Use strong, unique passphrases for unlock keys
2. Enable platform-specific authentication (Keychain) when available
3. Regularly audit unlock keys and remove unused ones
1. Use strong, unique passphrases for unlockers
2. Enable hardware authentication (hardware tokens) when available
3. Regularly audit unlockers and remove unused ones
4. Keep mnemonic phrases securely backed up offline
5. Use separate vaults for different security contexts
### Limitations
- Requires access to unlock keys for secret retrieval
- Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up
- Platform-specific features limited to supported platforms
## Development
### Building
```bash
go build -o secret ./cmd/secret # Build binary
go test ./... # Run tests
go vet ./... # Run static analysis
make build # Build binary
make test # Run tests
make lint # Run linter
```
### Testing
The project includes comprehensive tests:
```bash
./test_secret_manager.sh # Full integration test suite
go test ./... # Unit tests
make test # Run all tests
```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap` — install all dependencies (Go, Go module download, and
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/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by other
scripts such as `script/docker`
- `script/build` — build the `secret` binary into the repo root, stamping the
version (`VERSION` from the environment, else `git describe`) and the git
commit
- `script/test` — build the `test` phase of the `Dockerfile`, which runs the
test suite with the race detector, a 90-second timeout and coverage; on
failure it reruns the tests verbosely for the details and fails even when the
rerun passes
- `script/lint` — build the `lint` phase of the `Dockerfile`, which runs
`go vet` and `golangci-lint`, then both again on the code as a macOS build
compiles it (`GOOS=darwin`, with cgo off), which a Linux build never compiles
- `script/fmt` — format all Go code with `go fmt` and every markdown file with
prettier (4-space tabs, `proseWrap: always`) (writes)
- `script/fmt-check` — check the same formatting without writing, on the host
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name; the
build runs the `lint` and `test` phases first
- `script/cibuild` — CI entrypoint: runs `script/bootstrap`, `script/check`,
then builds the image as `script/docker` does
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
`script/check`
- `script/install-precommit` — install the git pre-commit hook that runs
`script/precommit`
## Features
- **Multiple Authentication Methods**: Supports passphrase-based, keychain-based (macOS), and PGP-based unlock keys
- **Multiple Authentication Methods**: Supports passphrase and PGP unlockers
- **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
## TODO
Open work is tracked on the
[issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
authoritative. The work to be done before 1.0 is the
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
records the steps completed so far.
## License
Released as a free software gift to the world, no strings attached, under the
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
## Author
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
in Berlin in the summer of 2025.
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
+679
View File
@@ -0,0 +1,679 @@
---
title: Repository Policies
last_modified: 2026-10-04
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`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
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
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.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
```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
# Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# 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}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default,
and a stage nothing depends on is not built at all, so without these two
lines a red gate would not fail the 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
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```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
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
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
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
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.
- **The test command should use the conditional verbose rerun pattern.** Run
tests without `-v` (verbose) first. If tests fail, automatically rerun with
`-v` to show full output. This keeps CI logs and `docker build` output clean
on success (just package/suite summaries) while providing full diagnostic
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
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
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:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
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
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
`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
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
- `bin/` — executable scripts and tools
- `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
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- 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.
+609 -197
View File
@@ -1,197 +1,609 @@
# TODO for 1.0 Release
This document outlines the bugs, issues, and improvements that need to be addressed before the 1.0 release of the secret manager.
## Critical (Blockers for Release)
### Error Handling and User Experience
- [ ] **1. Inappropriate Cobra usage printing**: Commands currently print usage information for all errors, including internal program failures. Usage should only be printed when the user provides incorrect arguments or invalid commands, not when the program encounters internal errors (like file system issues, crypto failures, etc.).
- [ ] **2. Inconsistent error messages**: Error messages need standardization and should be user-friendly. Many errors currently expose internal implementation details.
- [ ] **3. Missing validation for vault names**: Vault names should be validated against a safe character set to prevent filesystem issues.
- [ ] **4. No graceful handling of corrupted state**: If key files are corrupted or missing, the tool should provide clear error messages and recovery suggestions.
### Core Functionality Bugs
- [ ] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
- [ ] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/`
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
- [ ] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
- [ ] **8. Missing current unlock key initialization**: When creating vaults, no default unlock key is selected, which can cause operations to fail.
- [ ] **9. Race conditions in file operations**: Multiple concurrent operations could corrupt the vault state due to lack of file locking.
### Security Issues
- [ ] **10. Insecure temporary file handling**: Temporary files containing sensitive data may not be properly cleaned up or secured.
- [ ] **11. Missing secure memory clearing**: Sensitive data in memory (passphrases, keys) should be cleared after use.
- [ ] **12. Weak default permissions**: Some files may be created with overly permissive default permissions.
## Important (Should be fixed before release)
### User Interface Improvements
- [ ] **13. Add confirmation prompts for destructive operations**: Operations like `keys rm` and vault deletion should require confirmation.
- [ ] **14. Improve progress indicators**: Long operations (key generation, encryption) should show progress.
- [ ] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
- [ ] **16. Add `--help` examples**: Command help should include practical examples for each operation.
### Command Implementation Gaps
- [ ] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
- [ ] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
- [ ] **19. Missing vault deletion command**: No way to delete vaults that are no longer needed.
- [ ] **20. No secret deletion command**: Missing `secret rm <secret-name>` functionality.
- [ ] **21. Missing secret history/versioning**: No way to see previous versions of secrets or restore old values.
### Configuration and Environment
- [ ] **22. Global configuration not fully implemented**: The `configuration.json` file structure exists but isn't used consistently.
- [ ] **23. Missing environment variable validation**: Environment variables should be validated for format and security.
- [ ] **24. No configuration file validation**: JSON configuration files should be validated against schemas.
### PGP Integration Issues
- [ ] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
- [ ] **26. Missing GPG agent integration**: Should detect and use existing GPG agent when available.
- [ ] **27. No validation of GPG key existence**: Should verify the specified GPG key exists before creating PGP unlock keys.
### Cross-Platform Issues
- [ ] **28. macOS Keychain error handling**: Better error messages when biometric authentication fails or isn't available.
- [ ] **29. Windows path handling**: File paths may not work correctly on Windows systems.
- [ ] **30. XDG compliance on Linux**: Should respect `XDG_CONFIG_HOME` and other XDG environment variables.
## Trivial (Nice to have)
### Code Quality
- [ ] **31. Add more comprehensive unit tests**: Current test coverage could be improved, especially for error conditions.
- [ ] **32. Reduce code duplication**: Several functions have similar patterns that could be refactored.
- [ ] **33. Improve function documentation**: Many functions lack proper Go documentation comments.
- [ ] **34. Add static analysis**: Integrate tools like `staticcheck`, `golangci-lint` with more linters.
### Performance Optimizations
- [ ] **35. Cache unlock key operations**: Avoid re-reading unlock key metadata on every operation.
- [ ] **36. Optimize file I/O**: Batch file operations where possible to reduce syscalls.
- [ ] **37. Add connection pooling for HSM operations**: For hardware security module operations.
### User Experience Enhancements
- [ ] **38. Add shell completion**: Bash/Zsh completion for commands and secret names.
- [ ] **39. Colored output**: Use colors to improve readability of lists and error messages.
- [ ] **40. Add `--quiet` flag**: Option to suppress non-essential output.
- [ ] **41. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
### Additional Features
- [ ] **42. Secret templates**: Predefined templates for common secret types (database URLs, API keys, etc.).
- [ ] **43. Bulk operations**: Import/export multiple secrets at once.
- [ ] **44. Secret sharing**: Secure sharing of secrets between vaults or users.
- [ ] **45. Audit logging**: Log all secret access and modifications.
- [ ] **46. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
### Documentation
- [ ] **47. Man pages**: Generate and install proper Unix man pages.
- [ ] **48. API documentation**: Document the internal API for potential library use.
- [ ] **49. Migration guide**: Document how to migrate from other secret managers.
- [ ] **50. Security audit documentation**: Document security assumptions and threat model.
## Architecture Improvements
### Code Structure
- [ ] **51. Consistent interface implementation**: Ensure all unlock key types properly implement the UnlockKey interface.
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
- [ ] **53. Improved error types**: Create specific error types instead of using generic `fmt.Errorf`.
### Testing Infrastructure
- [ ] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
- [ ] **55. Integration test isolation**: Tests should not affect each other or the host system.
- [ ] **56. Performance benchmarks**: Add benchmarks for crypto operations and file I/O.
## Technical Debt
- [ ] **57. Remove unused code**: Clean up any dead code or unused imports.
- [ ] **58. Standardize JSON schemas**: Create proper JSON schemas for all configuration files.
- [ ] **59. Improve error propagation**: Many functions swallow important context in error messages.
- [ ] **60. Consistent naming conventions**: Some variables and functions use inconsistent naming.
## Development Workflow
- [ ] **61. Add pre-commit hooks**: Ensure code quality and formatting before commits.
- [ ] **62. Continuous integration**: Set up CI/CD pipeline with automated testing.
- [ ] **63. Release automation**: Automate the build and release process.
- [ ] **64. Dependency management**: Regular updates and security scanning of dependencies.
---
## Priority Assessment
**Critical items** (1-12) block the 1.0 release and must be fixed for basic functionality and security.
**Important items** (13-30) should be addressed for a polished user experience but don't block the release.
**Trivial items** (31-50) are enhancements that can be addressed in future releases.
**Architecture and Infrastructure** (51-64) are longer-term improvements for maintainability and development workflow.
## Estimated Timeline
- Critical (1-12): 2-3 weeks
- Important (13-30): 3-4 weeks
- Trivial (31-50): Ongoing post-1.0
- Architecture/Infrastructure (51-64): Ongoing post-1.0
Total estimated time to 1.0: 5-7 weeks with focused development effort.
# Workflow
- branch from `next`
- do the Next Step: the next open issue in the `1.0.0` milestone
- log it at the top of Completed Steps
- commit (`TODO.md` changes in the same commit as the work)
- push, and open a PR against `next`
# Status
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
authoritative.
# Next Step
Take the next open issue in the `1.0.0` milestone:
https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
prompt, gives the error `wrong passphrase for unlocker <id>`
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
identity matched any of the recipients", followed by advice to give the vault
a new unlocker from its mnemonic, which would have replaced an unlocker that
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
to use the mnemonic. A `priv.age` that is not an age file still gets that
advice.
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
for the passphrase at a terminal instead of failing
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
controlling terminal gets the error that names the variable to set. On SIGINT
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
since that is where the prompt turns echo off. `secret init` and
`secret vault create` no longer write a newline to stderr after the mnemonic
is typed, since the prompt ends that line on the terminal.
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
`secret unlocker list` print their output to stdout, as `secret list` does, so
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
stderr when no output writer is set, so `secret vault list --json | jq`
received nothing. "No versions found" from `secret version list` moved to
stdout too. Messages such as "Created vault", and the warning after the
mnemonic, stay on stderr.
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
open through the unlockers it still has
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
naming the removed unlocker, so every command that opened the vault failed,
with advice to use the mnemonic. `secret unlocker remove` of the current
unlocker now makes the first of the vault's other unlockers that
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
current before removing it, and says so; when there is none, it also removes
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
made current. When `current-unlocker` names an unlocker that does not exist
and the vault has another that `secret unlocker select` accepts, the error
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
without the advice to use the mnemonic.
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
passphrase or mnemonic, leaves the secret as it was
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
written before the vault was unlocked to set `notAfter` on the version it
replaces, so each failed add left one more version that `secret version list`
showed. The vault is now unlocked, and that version's metadata read, before
anything is written. `generate secret --force` and `import --force` take the
same path.
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
text exactly as typed, so a vault made with a double or trailing space did not
open with the same words typed normally. `agehd.DeriveEntropy`, which every
use of the mnemonic goes through, now joins the words with single spaces and
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code
and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were
research: neither works without macOS code signing and entitlements, which
`secret` does not have. The last commit that has them is `ef0ae90`, tagged
`v1.0.0-rc1`. `secret unlocker add` takes only `passphrase` and `pgp` on every
platform, `internal/macse` and those unlockers' files in `internal/secret` are
deleted, and `github.com/keybase/go-keychain` left `go.mod` and `go.sum`.
`secret` still builds and runs on macOS.
- 2026-10-07: The part of `github.com/tyler-smith/go-bip39` v1.1.0 that `secret`
uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it,
because its repository no longer exists
(https://git.eeqj.de/sneak/secret/issues/122). Every import moved there, and
the module left `go.mod` and `go.sum`. No derived key or mnemonic changes.
- 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
not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
now takes the unlocker passphrase too, writes the vault directory with its
metadata, long-term public key and passphrase unlocker, `longterm.age`
included, into a temporary directory, renames that into `vaults.d` once it is
complete, and only then makes the vault current. Before, either command killed
after the passphrase prompt but before the unlocker was written left a vault
with no unlocker, which `vault create` had already made current and which
neither command would create again. Killed part-way now, it leaves no vault,
and the next command that takes the lock deletes the temporary directory; or,
killed between the rename and making the vault current, a complete vault that
is not current, which `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
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
`secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
encrypted keys included, is deleted by the next command that takes the state
directory lock. Before, it stayed until deleted by hand. A command writes
`finished` into the lock file just before it releases the lock; the next one
to take the lock searches only when it does not find that, so after a command
that finished nothing is searched, however many secrets and versions there
are. The search looks in the state directory, each vault, each secret and each
version, the only directories those helpers make them in. A command that only
reads 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
already removed by `secret unlocker remove` given its directory name; a test
now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer through
`secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
unlocker's own key, a new secret version's key, and the key `secret encrypt`
generates. Before, each place converted the string age returns to bytes and
left the string in ordinary memory. The function moves the string's own bytes
into the buffer, which overwrites them; the copies age makes while writing the
string remain, as its comment says. The 1.0 memory-security entry below no
longer lists these places, `internal/cli/crypto.go` among them, nor
`version.go:155`, which was `internal/secret/version.go`, not
`internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50).
`script/check` runs it, and the `Dockerfile` lint stage runs its commands, so
`script/cibuild` does too. Before, CI on Linux never compiled the files built
only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and
both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS.
So the three functions that call `go-keychain` moved from
`keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on
macOS like `macse_darwin.go`. A macOS build without cgo, which before did not
compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose
errors say the keychain or Secure Enclave needs a macOS build with cgo. The
check covers the rest of the keychain unlocker, the Secure Enclave unlocker
and the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into
functions of their own, and the Secure Enclave unlocker derives the long-term
key from the mnemonic through the same function as the keychain unlocker
instead of a copy of it. Lines over 88 columns in the files the check cannot
see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then opens
only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
command whose stdin is not a terminal fails at once. `--force` (now also on
`rm` and `version rm`) removes without asking; it replaces the old refusals to
remove a vault with secrets or the last unlocker of one without `--force`,
which the question now covers. The checks run, and the question is asked,
before the state directory lock is taken; under the lock the checks run again,
and if they would ask a different question, nothing is removed. `secret rm`
fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
unlocker the keychain item or Secure Enclave key, which names the directory,
carries the time instead of the day. `secret.WriteDir` fails on a directory
that exists instead of writing into it. `unlocker add passphrase` writes the
new unlocker, makes it current, and only then removes the vault's other
passphrase unlockers; a crash between the last two steps leaves the old one
beside the new, and the old passphrase still opens the vault through it until
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
keychain or Secure Enclave unlocker added on the same host and day as another
of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
at once, so that no program the command runs, `gpg` included, inherits them
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
the environment; the buffers are passed down: `vault.CreateVault` takes the
mnemonic (nil for none), a `Vault` derives its long-term key from its
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
PGP, keychain and Secure Enclave unlocker constructors take both.
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
into the environment. Unsetting erases nothing: the starting environment
(`/proc/<pid>/environ`) and memory still hold the value. The README warns
against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
of non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
vault's long-term key as adding a passphrase unlocker does, with the vault's
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
checked against the vault, or else from the current unlocker. Before, it used
the keychain unlocker's helper, which on every platform but macOS always
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
long-term key once from the mnemonic and once from a passphrase unlocker, and
reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
`-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
vault names of `mv` and shell completion of a `vault:secret` argument check
the name as typed with `vault.ValidateVaultName` before building any path from
it. Before, `vault import ..` wrote a long-term key and an unlocker into the
state directory itself, and `vault select ..` made that the current vault.
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
the `CHECK_EPOCH` build argument, which both the lint and the build stage of
the `Dockerfile` declare after their module download, so the `RUN` steps below
the argument run again on each build while the base images and module
downloads stay cached. Before, a second run on the same tree took every check
from the build cache and reported success having run nothing.
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
encrypt everything before writing anything. All four unlocker types write
their files through `secret.WriteDir`: a new unlocker is built in a temporary
directory, renamed into place when complete and removed on a failure.
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
the warning `unlocker list` gives, an unlocker directory whose metadata file
cannot be checked for, read or parsed, instead of failing when it sorts before
the unlocker asked for. Such a directory, or one without a metadata file, is
removed by its directory name, the name the warning gives; only the directory
is removed, since its type is unknown. Removing one whose metadata file is
missing or corrupt never counts as removing the last unlocker. Removing one
whose metadata file cannot be checked for or read always does, since it may be
the only working unlocker, so in a vault with secrets it needs `--force`.
- 2026-10-04: A failed command prints its error once, without the usage text
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
for a command called wrongly: wrong number of arguments, unknown flag, bad
flag value, missing required flag, or flags that break a flag group (mutually
exclusive, required together, one required). The root command's
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
before that hook but required flags and flag groups only after it, so the hook
checks those two first. Root `SilenceUsage` would have hidden usage for all of
these.
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
destroys, and `secret get` writes its bytes straight to stdout, still with no
trailing newline. Before, the value was copied into ordinary memory that
nothing wiped, and `get --version` also wrote it to the debug log.
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
`make build` calls the new `script/build`, which stamps the version (`VERSION`
from the environment, else `git describe`) and the git commit as before.
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
first.
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
`/secret`, `*.log`, `*.test` and `settings.local.json`
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
`node_modules`; `.git` stays in the build context for the version stamp.
- 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already exists",
before writing anything. The check is in `vault.CreateVault`, which both
commands call while holding the state directory lock, so two creates of one
vault at once cannot both pass the check. Before, either command replaced the
vault's metadata, passphrase unlocker and `longterm.age`, so none of its
secrets could be decrypted any more. Both commands now ask for the unlocker
passphrase before creating the vault, so one stopped at that prompt leaves no
vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time before the
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
that each changing command waits for the lock releases it as soon as it sees
the command waiting there, instead of after a fixed 100 ms. The two vaults
with passphrase unlockers that the path and move tests start from are made
once and copied for each test.
- 2026-10-04: `secret mv` rejects a move whose destination is the source under
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
macOS default) or a name reached through a symbolic link, before changing
anything, with or without `--force`, within a vault and between vaults;
before, `--force` removed the destination and so deleted the secret. A rename
that changes only letter case works on a case-sensitive filesystem as before.
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
where golangci-lint is a build step rebuilt on every run
(`--no-cache-filter`), so an unchanged tree is linted too; the module download
stays cached. `script/bootstrap` no longer installs golangci-lint, and the
`Dockerfile` lint stage calls it directly instead of `make lint`.
`golangci-lint config verify` is not run: it fetches its schema live over
unpinned HTTPS.
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
panics: `GetID()` warns with the unlocker's directory and returns
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
metadata file cannot be checked for, read or parsed instead of failing, so
`secret unlocker list` still lists the others; the listing's ID lookup no
longer warns about that directory again.
- 2026-10-03: `secret mv` rejects a move whose destination is the source
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
defaults to the source name) before changing anything; before, `--force`
removed the destination first and so deleted the secret. Every vault name
given with `vault:` must be one of the existing vaults by exact name, so
`work:x work/:x` is rejected instead of being taken for a move between two
vaults. A move within a named vault no longer makes that vault the current
one, whether it succeeds or fails.
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on
`lock` in the state directory; a mutex on the in-memory test filesystem), so
concurrent commands no longer lose versions or race on the current pointers.
Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
rename), so no file is ever half-written and `current`, `currentvault` and
`current-unlocker` never go missing. New versions, new secrets and cross-vault
copies are built in a temporary directory and renamed into place, and removals
rename out of the way first, so a version or secret is never half-added and
never half-removed.
- 2026-10-03: The checks run before changing a vault now stop with an error
naming the path and cause when they cannot read what they inspect, instead of
reading the failure as "nothing there": the duplicate check before
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
the secret count that guards removing the last unlocker and removing a vault,
and the existing long-term key check before `vault import`.
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a
version only if it is one of the versions `version list` lists for that
secret, compared as typed before any path is built (`secret.VersionExists`),
and touch nothing otherwise. An empty `--version` is rejected instead of
meaning the current version. Before, `secret version rm x ../../..` deleted
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
version.
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
code after its deferred `memguard.Purge()` has run, and only `main` calls
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
buffer before exiting; when the process is in the terminal's foreground
process group it first restores the terminal settings from startup, so an
interrupted passphrase prompt no longer leaves echo off.
- 2026-10-03: Every command that builds a path from a secret name checks the
name first with `vault.ValidateSecretName` and touches nothing when it is
invalid: `rm`, `mv` (both names, within a vault and between vaults, before
switching the current vault), `import`, `version list`/`promote`/`rm`,
`encrypt` and `decrypt`. The error and `README.md` state the naming rule.
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
in it.
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
it is generated into a locked buffer, and the keychain JSON is written and
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
Linux) without `encoding/json` holding it; the JSON field names are unchanged.
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
that needs more locked memory than the process can lock, and run every case
under `script/cibuild`. The image stamps the `VERSION` build argument, else
`git describe --tags --always`, into `Version`, and fails if `.git` is present
but yields no version; `make build` stamps `git describe` too, not a fixed
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable list, `lll`
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
`pkg/bip85` were reshaped so every composed error message is byte-identical to
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
skips an unreadable `unlockers.d` entry with a warning instead of emitting a
fabricated fallback ID.
- 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
plus review fixes (stub panics, derivation index, tests, README) on branch
secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
metadata, allow uppercase secret names, fix hardcoded derivation index,
validate names in GetSecretVersion against path traversal, return errors
instead of panicking, add Warn() on silent anomalies.
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
removed.
- Per-secret keypair architecture, vault package refactor, versioning with
--version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
# Future Steps
- Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` (`internal/cli/version.go`;
was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
- CI does not compile, lint or test the files built only with cgo on macOS,
since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
run on them, so it would likely find more there than the line lengths. No
macOS test runs in CI. A macOS runner would cover all of it (asked on
https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md):
- 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
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
- Medium priority:
- Standardize error messages; stop leaking internals.
- Split oversized CLI functions.
- Cleanups: read statedir from environment or default instead of passing it
around.
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
miss, audit logging, hardware integration tests (Keychain, GPG), naming
consistency, vault export/import, batch operations, search, secret metadata
(descriptions, tags).
+8 -1
View File
@@ -1,5 +1,12 @@
// Package main is the entry point for the secret CLI application.
package main
import (
"os"
"sneak.berlin/go/secret/internal/cli"
)
func main() {
CLIEntry()
os.Exit(cli.Entry())
}
+15 -20
View File
@@ -1,41 +1,36 @@
module git.eeqj.de/sneak/secret
module sneak.berlin/go/secret
go 1.24.1
require (
filippo.io/age v1.2.1
github.com/awnumar/memguard v0.22.5
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
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/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
github.com/tyler-smith/go-bip39 v1.1.0
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
)
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/awnumar/memcall v0.2.0 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c // indirect
github.com/facebookincubator/sks v0.0.0-20250508161834-9be892919529 // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2 // indirect
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+18 -1195
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2018 Tyler Smith and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+285
View File
@@ -0,0 +1,285 @@
// Package bip39 is the Golang implementation of the BIP39 spec.
//
// The official BIP39 spec can be found at
// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
//
// It is a copy of github.com/tyler-smith/go-bip39 v1.1.0, trimmed to what
// secret uses.
//
//nolint:mnd // the numbers are BIP-39's own, written as upstream writes them
package bip39
import (
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/binary"
"errors"
"fmt"
"math/big"
"strings"
"golang.org/x/crypto/pbkdf2"
)
var (
// ErrInvalidMnemonic is returned when trying to use a malformed mnemonic.
ErrInvalidMnemonic = errors.New("invalid mnenomic")
// ErrEntropyLengthInvalid is returned when trying to use an entropy set with
// an invalid size.
ErrEntropyLengthInvalid = errors.New(
"entropy length must be [128, 256] and a multiple of 32",
)
// ErrChecksumIncorrect is returned when entropy has the incorrect checksum.
ErrChecksumIncorrect = errors.New("checksum incorrect")
)
// NewEntropy will create random entropy bytes
// so long as the requested size bitSize is an appropriate size.
//
// bitSize has to be a multiple 32 and be within the inclusive range of {128, 256}
func NewEntropy(bitSize int) ([]byte, error) {
err := validateEntropyBitSize(bitSize)
if err != nil {
return nil, err
}
entropy := make([]byte, bitSize/8)
_, err = rand.Read(entropy)
return entropy, err
}
// EntropyFromMnemonic takes a mnemonic generated by this library,
// and returns the input entropy used to generate the given mnemonic.
// An error is returned if the given mnemonic is invalid.
func EntropyFromMnemonic(mnemonic string) ([]byte, error) {
mnemonicSlice, isValid := splitMnemonicWords(mnemonic)
if !isValid {
return nil, ErrInvalidMnemonic
}
// Some bitwise operands for working with big.Ints
shift11BitsMask := big.NewInt(2048)
bigOne := big.NewInt(1)
// used to isolate the checksum bits from the entropy+checksum byte array
wordLengthChecksumMasksMapping := map[int]*big.Int{
12: big.NewInt(15),
15: big.NewInt(31),
18: big.NewInt(63),
21: big.NewInt(127),
24: big.NewInt(255),
}
// used to use only the desired x of 8 available checksum bits.
// 256 bit (word length 24) requires all 8 bits of the checksum,
// and thus no shifting is needed for it (we would get a divByZero crash if we did)
wordLengthChecksumShiftMapping := map[int]*big.Int{
12: big.NewInt(16),
15: big.NewInt(8),
18: big.NewInt(4),
21: big.NewInt(2),
}
// wordMap is a reverse lookup map for the word list
wordMap := map[string]int{}
for i, v := range English() {
wordMap[v] = i
}
// Decode the words into a big.Int.
b := big.NewInt(0)
for _, v := range mnemonicSlice {
index, found := wordMap[v]
if !found {
return nil, fmt.Errorf(
"%w: word `%v` not found in reverse map", ErrInvalidMnemonic, v,
)
}
var wordBytes [2]byte
//nolint:gosec // the index of a word in the list is below 2048
binary.BigEndian.PutUint16(wordBytes[:], uint16(index))
b = b.Mul(b, shift11BitsMask)
b = b.Or(b, big.NewInt(0).SetBytes(wordBytes[:]))
}
// Build and add the checksum to the big.Int.
checksum := big.NewInt(0)
checksumMask := wordLengthChecksumMasksMapping[len(mnemonicSlice)]
checksum = checksum.And(b, checksumMask)
b.Div(b, big.NewInt(0).Add(checksumMask, bigOne))
// The entropy is the underlying bytes of the big.Int. Any upper bytes of
// all 0's are not returned so we pad the beginning of the slice with empty
// bytes if necessary.
entropy := b.Bytes()
entropy = padByteSlice(entropy, len(mnemonicSlice)/3*4)
// Generate the checksum and compare with the one we got from the mneomnic.
entropyChecksumBytes := computeChecksum(entropy)
entropyChecksum := big.NewInt(int64(entropyChecksumBytes[0]))
if l := len(mnemonicSlice); l != 24 {
checksumShift := wordLengthChecksumShiftMapping[l]
entropyChecksum.Div(entropyChecksum, checksumShift)
}
if checksum.Cmp(entropyChecksum) != 0 {
return nil, ErrChecksumIncorrect
}
return entropy, nil
}
// NewMnemonic will return a string consisting of the mnemonic words for
// the given entropy.
// If the provide entropy is invalid, an error will be returned.
func NewMnemonic(entropy []byte) (string, error) {
// Compute some lengths for convenience.
entropyBitLength := len(entropy) * 8
checksumBitLength := entropyBitLength / 32
sentenceLength := (entropyBitLength + checksumBitLength) / 11
// Validate that the requested size is supported.
err := validateEntropyBitSize(entropyBitLength)
if err != nil {
return "", err
}
// Some bitwise operands for working with big.Ints
last11BitsMask := big.NewInt(2047)
shift11BitsMask := big.NewInt(2048)
// wordList is the set of words to use
wordList := English()
// Add checksum to entropy.
entropy = addChecksum(entropy)
// Break entropy up into sentenceLength chunks of 11 bits.
// For each word AND mask the rightmost 11 bits and find the word at that index.
// Then bitshift entropy 11 bits right and repeat.
// Add to the last empty slot so we can work with LSBs instead of MSB.
// Entropy as an int so we can bitmask without worrying about bytes slices.
entropyInt := new(big.Int).SetBytes(entropy)
// Slice to hold words in.
words := make([]string, sentenceLength)
// Throw away big.Int for AND masking.
word := big.NewInt(0)
for i := sentenceLength - 1; i >= 0; i-- {
// Get 11 right most bits and bitshift 11 to the right for next time.
word.And(entropyInt, last11BitsMask)
entropyInt.Div(entropyInt, shift11BitsMask)
// Get the bytes representing the 11 bits as a 2 byte slice.
wordBytes := padByteSlice(word.Bytes(), 2)
// Convert bytes to an index and add that word to the list.
words[i] = wordList[binary.BigEndian.Uint16(wordBytes)]
}
return strings.Join(words, " "), nil
}
// NewSeed creates a hashed seed output given a provided string and password.
// No checking is performed to validate that the string provided is a valid mnemonic.
func NewSeed(mnemonic string, password string) []byte {
return pbkdf2.Key([]byte(mnemonic), []byte("mnemonic"+password), 2048, 64, sha512.New)
}
// IsMnemonicValid attempts to verify that the provided mnemonic is valid.
// Validity is determined by both the number of words being appropriate,
// and that all the words in the mnemonic are present in the word list.
func IsMnemonicValid(mnemonic string) bool {
_, err := EntropyFromMnemonic(mnemonic)
return err == nil
}
// Appends to data the first (len(data) / 32)bits of the result of sha256(data)
// Currently only supports data up to 32 bytes
func addChecksum(data []byte) []byte {
// Some bitwise operands for working with big.Ints
bigOne := big.NewInt(1)
bigTwo := big.NewInt(2)
// Get first byte of sha256
hash := computeChecksum(data)
firstChecksumByte := hash[0]
// len() is in bytes so we divide by 4
checksumBitLength := uint(len(data) / 4)
// For each bit of check sum we want we shift the data one the left
// and then set the (new) right most bit equal to checksum bit at that index
// staring from the left
dataBigInt := new(big.Int).SetBytes(data)
for i := range checksumBitLength {
// Bitshift 1 left
dataBigInt.Mul(dataBigInt, bigTwo)
// Set rightmost bit if leftmost checksum bit is set
if firstChecksumByte&(1<<(7-i)) > 0 {
dataBigInt.Or(dataBigInt, bigOne)
}
}
return dataBigInt.Bytes()
}
func computeChecksum(data []byte) []byte {
hasher := sha256.New()
hasher.Write(data)
return hasher.Sum(nil)
}
// validateEntropyBitSize ensures that entropy is the correct size for being a
// mnemonic.
func validateEntropyBitSize(bitSize int) error {
if (bitSize%32) != 0 || bitSize < 128 || bitSize > 256 {
return ErrEntropyLengthInvalid
}
return nil
}
// padByteSlice returns a byte slice of the given size with contents of the
// given slice left padded and any empty spaces filled with 0's.
func padByteSlice(slice []byte, length int) []byte {
offset := length - len(slice)
if offset <= 0 {
return slice
}
newSlice := make([]byte, length)
copy(newSlice[offset:], slice)
return newSlice
}
func splitMnemonicWords(mnemonic string) ([]string, bool) {
// Create a list of all the words in the mnemonic sentence
words := strings.Fields(mnemonic)
// Get num of words
numOfWords := len(words)
// The number of words should be 12, 15, 18, 21 or 24
if numOfWords%3 != 0 || numOfWords < 12 || numOfWords > 24 {
return nil, false
}
return words, true
}
+456
View File
@@ -0,0 +1,456 @@
package bip39
import (
"encoding/hex"
"testing"
)
type vector struct {
entropy string
mnemonic string
seed string
}
func TestNewMnemonic(t *testing.T) {
t.Parallel()
for _, vector := range testVectors() {
entropy, err := hex.DecodeString(vector.entropy)
assertNil(t, err)
mnemonic, err := NewMnemonic(entropy)
assertNil(t, err)
assertEqualString(t, vector.mnemonic, mnemonic)
seed := NewSeed(mnemonic, "TREZOR")
assertEqualString(t, vector.seed, hex.EncodeToString(seed))
}
}
func TestNewMnemonicInvalidEntropy(t *testing.T) {
t.Parallel()
_, err := NewMnemonic([]byte{})
assertNotNil(t, err)
}
func TestIsMnemonicValid(t *testing.T) {
t.Parallel()
for _, vector := range badMnemonicSentences() {
assertFalse(t, IsMnemonicValid(vector.mnemonic))
}
for _, vector := range testVectors() {
assertTrue(t, IsMnemonicValid(vector.mnemonic))
}
}
func TestNewEntropy(t *testing.T) {
t.Parallel()
// Good tests.
for i := 128; i <= 256; i += 32 {
_, err := NewEntropy(i)
assertNil(t, err)
}
// Bad Values
for i := range 257 {
if i%8 != 0 {
_, err := NewEntropy(i)
assertNotNil(t, err)
}
}
}
func TestPadByteSlice(t *testing.T) {
t.Parallel()
assertEqualByteSlices(t, []byte{0}, padByteSlice([]byte{}, 1))
assertEqualByteSlices(t, []byte{0, 1}, padByteSlice([]byte{1}, 2))
assertEqualByteSlices(t, []byte{1, 1}, padByteSlice([]byte{1, 1}, 2))
assertEqualByteSlices(t, []byte{1, 1, 1}, padByteSlice([]byte{1, 1, 1}, 2))
}
//nolint:funlen // the test vectors, kept as upstream wrote them
func TestMnemonicToByteArrayForZeroLeadingSeeds(t *testing.T) {
t.Parallel()
ms := []string{
"00000000000000000000000000000000",
"00a84c51041d49acca66e6160c1fa999",
"00ca45df1673c76537a2020bfed1dafd",
"0019d5871c7b81fd83d474ef1c1e1dae",
"00dcb021afb35ffcdd1d032d2056fc86",
"0062be7bd09a27288b6cf0eb565ec739",
"00dc705b5efa0adf25b9734226ba60d4",
"0017747418d54c6003fa64fade83374b",
"000d44d3ee7c3dfa45e608c65384431b",
"008241c1ef976b0323061affe5bf24b9",
"00a6aec77e4d16bea80b50a34991aaba",
"0011527b8c6ddecb9d0c20beccdeb58d",
"001c938c503c8f5a2bba2248ff621546",
"0002f90aaf7a8327698f0031b6317c36",
"00bff43071ed7e07f77b14f615993bac",
"00da143e00ef17fc63b6fb22dcc2c326",
"00ffc6764fb32a354cab1a3ddefb015d",
"0062ef47e0985e8953f24760b7598cdd",
"003bf9765064f71d304908d906c065f5",
"00993851503471439d154b3613947474",
"007ad0ffe9eae753a483a76af06dfa67",
"00091824db9ec19e663bee51d64c83cc",
"00f48ac621f7e3cb39b2012ac3121543",
"0072917415cdca24dfa66c4a92c885b4",
"0027ced2b279ea8a91d29364487cdbf4",
"00b9c0d37fb10ba272e55842ad812583",
"004b3d0d2b9285946c687a5350479c8c",
"00c7c12a37d3a7f8c1532b17c89b724c",
"00f400c5545f06ae17ad00f3041e4e26",
"001e290be10df4d209f247ac5878662b",
"00bf0f74568e582a7dd1ee64f792ec8b",
"00d2e43ecde6b72b847db1539ed89e23",
"00cecba6678505bb7bfec8ed307251f6",
"000aeed1a9edcbb4bc88f610d3ce84eb",
"00d06206aadfc25c2b21805d283f15ae",
"00a31789a2ab2d54f8fadd5331010287",
"003493c5f520e8d5c0483e895a121dc9",
"004706112800b76001ece2e268bc830e",
"00ab31e28bb5305be56e38337dbfa486",
"006872fe85df6b0fa945248e6f9379d1",
"00717e5e375da6934e3cfdf57edaf3bd",
"007f1b46e7b9c4c76e77c434b9bccd6b",
"00dc93735aa35def3b9a2ff676560205",
"002cd5dcd881a49c7b87714c6a570a76",
"0013b5af9e13fac87e0c505686cfb6bf",
"007ab1ec9526b0bc04b64ae65fd42631",
"00abb4e11d8385c1cca905a6a65e9144",
"00574fc62a0501ad8afada2e246708c3",
"005207e0a815bb2da6b4c35ec1f2bf52",
"00f3460f136fb9700080099cbd62bc18",
"007a591f204c03ca7b93981237112526",
"00cfe0befd428f8e5f83a5bfc801472e",
"00987551ac7a879bf0c09b8bc474d9af",
"00cadd3ce3d78e49fbc933a85682df3f",
"00bfbf2e346c855ccc360d03281455a1",
"004cdf55d429d028f715544ce22d4f31",
"0075c84a7d15e0ac85e1e41025eed23b",
"00807dddd61f71725d336cab844d2cb5",
"00422f21b77fe20e367467ed98c18410",
"00b44d0ac622907119c626c850a462fd",
"00363f5e7f22fc49f3cd662a28956563",
"000fe5837e68397bbf58db9f221bdc4e",
"0056af33835c888ef0c22599686445d3",
"00790a8647fd3dfb38b7e2b6f578f2c6",
"00da8d9009675cb7beec930e263014fb",
"00d4b384540a5bb54aa760edaa4fb2fe",
"00be9b1479ed680fdd5d91a41eb926d0",
"009182347502af97077c40a6e74b4b5c",
"00f5c90ee1c67fa77fd821f8e9fab4f1",
"005568f9a2dd6b0c0cc2f5ba3d9cac38",
"008b481f8678577d9cf6aa3f6cd6056b",
"00c4323ece5e4fe3b6cd4c5c932931af",
"009791f7550c3798c5a214cb2d0ea773",
"008a7baab22481f0ad8167dd9f90d55c",
"00f0e601519aafdc8ff94975e64c946d",
"0083b61e0daa9219df59d697c270cd31",
}
for _, m := range ms {
seed, _ := hex.DecodeString(m)
mnemonic, err := NewMnemonic(seed)
if err != nil {
t.Errorf("%v", err)
}
_, err = EntropyFromMnemonic(mnemonic)
if err != nil {
t.Errorf("Failed for %x - %v", seed, mnemonic)
}
}
}
func TestEntropyFromMnemonic128(t *testing.T) {
t.Parallel()
testEntropyFromMnemonic(t, 128)
}
func TestEntropyFromMnemonic160(t *testing.T) {
t.Parallel()
testEntropyFromMnemonic(t, 160)
}
func TestEntropyFromMnemonic192(t *testing.T) {
t.Parallel()
testEntropyFromMnemonic(t, 192)
}
func TestEntropyFromMnemonic224(t *testing.T) {
t.Parallel()
testEntropyFromMnemonic(t, 224)
}
func TestEntropyFromMnemonic256(t *testing.T) {
t.Parallel()
testEntropyFromMnemonic(t, 256)
}
//nolint:dupword,lll // the test vector, kept as upstream wrote it
func TestEntropyFromMnemonicInvalidChecksum(t *testing.T) {
t.Parallel()
_, err := EntropyFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon yellow")
assertEqual(t, ErrChecksumIncorrect, err)
}
//nolint:dupword // the test vectors, kept as upstream wrote them
func TestEntropyFromMnemonicInvalidMnemonicSize(t *testing.T) {
t.Parallel()
for _, mnemonic := range []string{
"a a a a a a a a a a a a a a a a a a a a a a a a a", // Too many words
"a", // Too few
"a a a a a a a a a a a a a a", // Not multiple of 3
} {
_, err := EntropyFromMnemonic(mnemonic)
assertEqual(t, ErrInvalidMnemonic, err)
}
}
func testEntropyFromMnemonic(t *testing.T, bitSize int) {
t.Helper()
for range 512 {
expectedEntropy, err := NewEntropy(bitSize)
assertNil(t, err)
assertTrue(t, len(expectedEntropy) != 0)
mnemonic, err := NewMnemonic(expectedEntropy)
assertNil(t, err)
assertTrue(t, len(mnemonic) != 0)
actualEntropy, err := EntropyFromMnemonic(mnemonic)
assertNil(t, err)
assertEqualByteSlices(t, expectedEntropy, actualEntropy)
}
}
//nolint:dupword,funlen,lll // the BIP-39 test vectors, kept as upstream wrote them
func testVectors() []vector {
return []vector{
{
entropy: "00000000000000000000000000000000",
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
seed: "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
},
{
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow",
seed: "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607",
},
{
entropy: "80808080808080808080808080808080",
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
seed: "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8",
},
{
entropy: "ffffffffffffffffffffffffffffffff",
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
seed: "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069",
},
{
entropy: "000000000000000000000000000000000000000000000000",
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent",
seed: "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa",
},
{
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
seed: "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd",
},
{
entropy: "808080808080808080808080808080808080808080808080",
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always",
seed: "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65",
},
{
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffff",
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when",
seed: "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528",
},
{
entropy: "0000000000000000000000000000000000000000000000000000000000000000",
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
seed: "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8",
},
{
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
seed: "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87",
},
{
entropy: "8080808080808080808080808080808080808080808080808080808080808080",
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
seed: "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f",
},
{
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
seed: "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
},
{
entropy: "77c2b00716cec7213839159e404db50d",
mnemonic: "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge",
seed: "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff",
},
{
entropy: "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b",
mnemonic: "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap",
seed: "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5",
},
{
entropy: "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982",
mnemonic: "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic",
seed: "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67",
},
{
entropy: "0460ef47585604c5660618db2e6a7e7f",
mnemonic: "afford alter spike radar gate glance object seek swamp infant panel yellow",
seed: "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4",
},
{
entropy: "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f",
mnemonic: "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left",
seed: "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba",
},
{
entropy: "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416",
mnemonic: "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste",
seed: "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449",
},
{
entropy: "eaebabb2383351fd31d703840b32e9e2",
mnemonic: "turtle front uncle idea crush write shrug there lottery flower risk shell",
seed: "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c",
},
{
entropy: "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78",
mnemonic: "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment",
seed: "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79",
},
{
entropy: "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef",
mnemonic: "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top",
seed: "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c",
},
{
entropy: "18ab19a9f54a9274f03e5209a2ac8a91",
mnemonic: "board flee heavy tunnel powder denial science ski answer betray cargo cat",
seed: "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8",
},
{
entropy: "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4",
mnemonic: "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief",
seed: "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9",
},
{
entropy: "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419",
mnemonic: "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut",
seed: "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd",
},
}
}
//nolint:dupword,lll // the test vectors, kept as upstream wrote them
func badMnemonicSentences() []vector {
return []vector{
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow yellow"},
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice caged above"},
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo, wrong"},
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will will will"},
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always."},
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo why"},
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art art"},
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thanks year wave worth useful legal winner thank year wave sausage worth title"},
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letters advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"},
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo voted"},
{mnemonic: "jello better achieve collect unaware mountain thought cargo oxygen act hood bridge"},
{mnemonic: "renew, stay, biology, evidence, goat, welcome, casual, join, adapt, armor, shuffle, fault, little, machine, walk, stumble, urge, swap"},
{mnemonic: "dignity pass list indicate nasty"},
// From issue 32
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon letter"},
}
}
func assertNil(t *testing.T, object any) {
t.Helper()
if object != nil {
t.Errorf("Expected nil, got %v", object)
}
}
func assertNotNil(t *testing.T, object any) {
t.Helper()
if object == nil {
t.Error("Expected not nil")
}
}
func assertTrue(t *testing.T, a bool) {
t.Helper()
if !a {
t.Error("Expected true, got false")
}
}
func assertFalse(t *testing.T, a bool) {
t.Helper()
if a {
t.Error("Expected false, got true")
}
}
func assertEqual(t *testing.T, a, b any) {
t.Helper()
if a != b {
t.Errorf("Objects not equal, expected `%s` and got `%s`", a, b)
}
}
func assertEqualString(t *testing.T, a, b string) {
t.Helper()
if a != b {
t.Errorf("Strings not equal, expected `%s` and got `%s`", a, b)
}
}
func assertEqualByteSlices(t *testing.T, a, b []byte) {
t.Helper()
if len(a) != len(b) {
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
return
}
for i := range a {
if a[i] != b[i] {
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
return
}
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
package bip39
import (
"hash/crc32"
"testing"
)
func TestEnglishChecksum(t *testing.T) {
t.Parallel()
// Ensure word list is correct
// $ wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt
// $ crc32 english.txt
// c1dbd296
checksum := crc32.ChecksumIEEE([]byte(english))
if checksum != 0xc1dbd296 {
t.Error("english checksum invalid")
}
}
+30
View File
@@ -0,0 +1,30 @@
package bip39_test
import (
"encoding/hex"
"fmt"
"sneak.berlin/go/secret/internal/bip39"
)
//nolint:lll // the test vector and its output, kept as upstream wrote them
func ExampleNewMnemonic() {
// the entropy can be any byte slice, generated how pleased,
// as long its bit size is a multiple of 32 and is within
// the inclusive range of {128,256}
entropy, _ := hex.DecodeString("066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad")
// generate a mnemomic
mnemomic, _ := bip39.NewMnemonic(entropy)
fmt.Println(mnemomic)
// output:
// all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform
}
//nolint:lll // the test vector and its output, kept as upstream wrote them
func ExampleNewSeed() {
seed := bip39.NewSeed("all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", "TREZOR")
fmt.Println(hex.EncodeToString(seed))
// output:
// 26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d
}
+122
View File
@@ -0,0 +1,122 @@
// Package cli implements the command-line interface for the secret application.
package cli
import (
"fmt"
"io"
"os"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
)
// Instance encapsulates all CLI functionality and state
type Instance struct {
fs afero.Fs
stateDir string
cmd *cobra.Command
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
// none.
Mnemonic *memguard.LockedBuffer
UnlockPassphrase *memguard.LockedBuffer
// terminal, when set, stands in for the terminal that confirm reads
// the user's answer from; only tests set it. When it is nil, confirm
// reads stdin, and only when stdin is a terminal.
terminal io.Reader
}
// NewCLIInstance creates a new CLI instance with the real filesystem
func NewCLIInstance() (*Instance, error) {
fs := afero.NewOsFs()
stateDir, err := secret.DetermineStateDir("")
if err != nil {
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{
fs: fs,
stateDir: stateDir,
}, nil
}
// NewCLIInstanceWithFs creates a new CLI instance with the given
// filesystem (for testing)
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
stateDir, err := secret.DetermineStateDir("")
if err != nil {
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{
fs: fs,
stateDir: stateDir,
}, nil
}
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state
// directory (for testing)
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
return &Instance{
fs: fs,
stateDir: stateDir,
}
}
// SetFilesystem sets the filesystem for this CLI instance (for testing)
func (cli *Instance) SetFilesystem(fs afero.Fs) {
cli.fs = fs
}
// SetStateDir sets the state directory for this CLI instance (for testing)
func (cli *Instance) SetStateDir(stateDir string) {
cli.stateDir = stateDir
}
// GetStateDir returns the state directory for this CLI instance
func (cli *Instance) GetStateDir() string {
return cli.stateDir
}
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
// either calls it once, before anything else, and passes the buffers on
// from there: each variable is unset as soon as it is read, so that the
// processes this one starts, gpg among them, do not inherit it, and a
// second read would find nothing. The returned function destroys both
// buffers.
func (cli *Instance) readSecretEnv() func() {
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
return func() {
if mnemonic != nil {
mnemonic.Destroy()
}
if passphrase != nil {
passphrase.Destroy()
}
}
}
// readAndUnsetEnv returns the value of the environment variable name in a
// locked buffer, or nil when it is unset or empty, and unsets the variable.
// Unsetting does not erase the value: it stays in this process's memory,
// and in /proc/<pid>/environ, which shows the environment the process
// started with. The caller must destroy the returned buffer.
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
value := os.Getenv(name)
_ = os.Unsetenv(name)
if value == "" {
return nil
}
return memguard.NewBufferFromBytes([]byte(value))
}
+76
View File
@@ -0,0 +1,76 @@
package cli_test
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
)
func TestCLIInstanceStateDir(t *testing.T) {
t.Parallel()
// Test the CLI instance state directory functionality
fs := afero.NewMemMapFs()
// Create a test state directory
testStateDir := "/test-state-dir"
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
got := instance.GetStateDir()
if got != testStateDir {
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
}
}
//nolint:paralleltest // reads process environment to determine the state dir
func TestCLIInstanceWithFs(t *testing.T) {
// Test creating CLI instance with custom filesystem
fs := afero.NewMemMapFs()
instance, err := cli.NewCLIInstanceWithFs(fs)
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
// The state directory should be determined automatically
stateDir := instance.GetStateDir()
if stateDir == "" {
t.Error("Expected non-empty state directory")
}
}
func TestDetermineStateDir(t *testing.T) {
// Test the determineStateDir function from the secret package
// Test with environment variable set
testEnvDir := "/test-env-dir"
t.Setenv(secret.EnvStateDir, testEnvDir)
stateDir, err := secret.DetermineStateDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateDir != testEnvDir {
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
}
// Test with custom config dir
_ = os.Unsetenv(secret.EnvStateDir)
customConfigDir := "/custom-config"
stateDir, err = secret.DetermineStateDir(customConfigDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedDir := filepath.Join(customConfigDir, secret.AppID)
if stateDir != expectedDir {
t.Errorf("Expected state directory %q with custom config, got %q",
expectedDir, stateDir)
}
}
+68
View File
@@ -0,0 +1,68 @@
package cli
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
)
// errUnsupportedShell is returned for unknown shell completion targets
var errUnsupportedShell = errors.New("unsupported shell type")
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate completion script",
Long: `To load completions:
Bash:
$ source <(secret completion bash)
# To load completions for each session, execute once:
# Linux:
$ secret completion bash > /etc/bash_completion.d/secret
# macOS:
$ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ secret completion zsh > "${fpath[1]}/_secret"
# You will need to start a new shell for this setup to take effect.
Fish:
$ secret completion fish | source
# To load completions for each session, execute once:
$ secret completion fish > ~/.config/fish/completions/secret.fish
PowerShell:
PS> secret completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> secret completion powershell > secret.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
}
},
}
return cmd
}
+185
View File
@@ -0,0 +1,185 @@
package cli
import (
"maps"
"slices"
"strings"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
)
// getSecretNamesCompletionFunc returns a completion function that provides
// secret names
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(
_ *cobra.Command, _ []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get list of secrets
secrets, err := vlt.ListSecrets()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Filter secrets based on what user has typed
var completions []string
for _, secret := range secrets {
if strings.HasPrefix(secret, toComplete) {
completions = append(completions, secret)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getUnlockerIDsCompletionFunc returns a completion function that provides
// unlocker IDs, the names of the unlockers' directories in unlockers.d
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(
_ *cobra.Command, _ []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
unlockerMetadata, err := vlt.ListUnlockers()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []string
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
if strings.HasPrefix(id, toComplete) {
completions = append(completions, id)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getVaultNamesCompletionFunc returns a completion function that provides
// vault names
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(
_ *cobra.Command, _ []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
vaults, err := vault.ListVaults(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []string
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// completeVaultQualifiedSecrets completes "vault:secret" references once a
// colon is present in the input. It completes nothing when the vault part
// is not a valid vault name, so that a name such as ".." cannot list a
// directory outside vaults.d.
func completeVaultQualifiedSecrets(
fs afero.Fs, stateDir, toComplete string,
) []string {
var completions []string
// Complete secret names for the specified vault
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
vaultName := parts[0]
secretPrefix := parts[1]
if vault.ValidateVaultName(vaultName) != nil {
return nil
}
vlt := vault.NewVault(fs, stateDir, vaultName)
secrets, err := vlt.ListSecrets()
if err == nil {
for _, secretName := range secrets {
if strings.HasPrefix(secretName, secretPrefix) {
completions = append(completions, vaultName+":"+secretName)
}
}
}
return completions
}
// completeUnqualifiedVaultSecrets completes vault names (with a ":"
// suffix) and secrets from the current vault
func completeUnqualifiedVaultSecrets(
fs afero.Fs, stateDir, toComplete string,
) []string {
var completions []string
// Complete vault names with ":" suffix
vaults, err := vault.ListVaults(fs, stateDir)
if err == nil {
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v+":")
}
}
}
// Also complete secrets from current vault (for within-vault moves)
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
if err == nil {
secrets, err := currentVlt.ListSecrets()
if err == nil {
for _, secretName := range secrets {
if strings.HasPrefix(secretName, toComplete) {
completions = append(completions, secretName)
}
}
}
}
return completions
}
// getVaultSecretCompletionFunc returns a completion function for the
// vault:secret format. It completes vault names with ":" suffix, and
// after ":" it completes secrets from that vault.
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(
_ *cobra.Command, _ []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Check if we're completing after a vault: prefix
if strings.Contains(toComplete, ":") {
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
cobra.ShellCompDirectiveNoFileComp
}
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
cobra.ShellCompDirectiveNoSpace
}
}
+41
View File
@@ -0,0 +1,41 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
// argument lists nothing when the vault part is not a valid vault name, even
// where that name, joined onto vaults.d, leads to a secrets.d directory.
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
t.Parallel()
const (
stateDir = "/state"
dirPerm = 0o700
)
fs := afero.NewMemMapFs()
// The vault "work" holds the secret "x". So does every directory an
// invalid name below would lead to from vaults.d.
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
}
assert.Equal(t, []string{"work:x"},
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
for _, toComplete := range []string{".:", "..:", "a/b:"} {
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
"completing %q", toComplete)
}
}
+108
View File
@@ -0,0 +1,108 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"sneak.berlin/go/secret/internal/vault"
)
// Sentinel errors for asking the user to confirm a removal
var (
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
"nobody to ask for confirmation; pass --force to remove without asking")
errNotConfirmed = errors.New("cancelled; nothing was removed")
errChangedWhileAsking = errors.New("what was to be removed changed " +
"while waiting for the answer; nothing was removed")
)
// askThenLock asks the user to confirm a removal, unless force is set, and
// then takes the state directory lock and returns the function that
// releases it. find makes the command's checks, keeps what it found for
// the caller to remove, and returns the question that names it. find runs
// before the question, which is asked without the lock so that no other
// command waits while the user answers, and runs again once the lock is
// taken. That run is the last, so the caller removes what find found under
// the lock. If its question then differs from the one the user answered,
// something changed in between, and askThenLock fails.
func (cli *Instance) askThenLock(
cmd *cobra.Command, force bool, find func() (string, error),
) (func(), error) {
asked := ""
if !force {
question, err := find()
if err != nil {
return nil, err
}
err = cli.confirm(cmd, question)
if err != nil {
return nil, err
}
asked = question
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return nil, err
}
question, err := find()
if err == nil && !force && question != asked {
err = errChangedWhileAsking
}
if err != nil {
release()
return nil, err
}
return release, nil
}
// confirm asks question and returns nil only when the user answers y or
// yes; any other answer, a bare Enter included, cancels. When stdin is not
// a terminal it asks nothing and fails at once: nobody is there to answer,
// and waiting for an answer would hang a script. Stdin decides, not
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
// still asks. The question goes to stderr.
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
answers := cli.terminal
if answers == nil {
answers = cmd.InOrStdin()
if !isTerminal(answers) {
return errNoTerminal
}
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
answer, err := bufio.NewReader(answers).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("failed to read the answer: %w", err)
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "y", "yes":
return nil
default:
return errNotConfirmed
}
}
// isTerminal reports whether r is a terminal.
func isTerminal(r io.Reader) bool {
file, ok := r.(*os.File)
return ok && term.IsTerminal(int(file.Fd()))
}
+411
View File
@@ -0,0 +1,411 @@
// Confirmation Tests
//
// `secret rm`, `secret version rm`, `secret vault remove` and
// `secret unlocker remove` ask the user to confirm on a terminal, naming
// what they are about to remove, and remove it only on y or yes. --force
// skips the question. Without --force, a command whose stdin is not a
// terminal fails at once, since nobody is there to answer.
//
// The tests answer through Instance.terminal, which stands in for a
// terminal. Without it, whether stdin is a terminal decides; the tests in
// integration_test.go that run `secret rm` on a pseudo-terminal cover that.
//nolint:testpackage // sets the unexported terminal field of Instance
package cli
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// confirmTestSecret is the secret the tests remove, or remove a
// version of, in the vault "work".
confirmTestSecret = "test/secret"
// lastUnlockerRemoval names the case that removes the only unlocker.
lastUnlockerRemoval = "unlocker rm, the last one"
)
// removal is one removal command, set up on its own state directory.
type removal struct {
fs afero.Fs
run func(cli *Instance, cmd *cobra.Command, force bool) error
// removed is the directory the command removes.
removed string
// question is the question the command asks.
question string
}
// newConfirmTestVaults returns an in-memory state directory with the
// vaults "other" and "work", the current one. "work" holds two versions of
// confirmTestSecret and the given number of PGP unlockers. It returns the
// directory of "work" and the older version.
func newConfirmTestVaults(
t *testing.T, unlockers int,
) (*afero.MemMapFs, string, string) {
t.Helper()
fs := &afero.MemMapFs{}
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
addTestSecret(t, vlt, []byte("newer"), true)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 2)
for i := range unlockers {
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
fmt.Sprintf("pgp-%d", i),
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)))
}
// ListVersions lists the newest version first.
return fs, vaultDir, versions[1]
}
// newRemoval sets up the removal the command names.
func newRemoval(t *testing.T, command string) removal {
t.Helper()
unlockers := 2
if command == lastUnlockerRemoval {
unlockers = 1
}
fs, workDir, older := newConfirmTestVaults(t, unlockers)
// The first unlocker's directory name, written by newConfirmTestVaults
unlockerID := "pgp-0"
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.UnlockersRemove(unlockerID, force, cmd)
}
switch command {
case "rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveSecret(cmd, confirmTestSecret, force)
},
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
question: "Permanently remove secret 'test/secret' and its 2 " +
"version(s) from vault 'work'?",
}
case "version rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
},
removed: filepath.Join(
workDir, "secrets.d", "test%secret", "versions", older),
question: "Permanently remove version " + older +
" of secret 'test/secret' from vault 'work'?",
}
case "vault rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVault(cmd, "work", force)
},
removed: workDir,
question: "Permanently remove vault 'work' and its 1 secret(s)?",
}
case "unlocker rm":
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"' from vault 'work'? It is not the vault's last unlocker.",
}
case lastUnlockerRemoval:
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"', the last unlocker of vault 'work', which holds 1 " +
"secret(s)? Without an unlocker the vault opens only " +
"with its mnemonic.",
}
}
t.Fatalf("no removal %q", command)
return removal{}
}
// removalCommands lists the commands newRemoval sets up.
func removalCommands() []string {
return []string{
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
}
}
// newConfirmTestCommand returns a command whose output is discarded and
// whose stderr, where the question goes, is the returned buffer.
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
return cmd, &stderr
}
// requireExists asserts whether the directory dir exists.
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
t.Helper()
exists, err := afero.DirExists(fs, dir)
require.NoError(t, err)
require.Equal(t, want, exists, dir)
}
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
// any case, around which spaces do not matter.
func TestConfirmAnswers(t *testing.T) {
t.Parallel()
for answer, want := range map[string]error{
"y\n": nil,
"Y\n": nil,
"yes\n": nil,
" YES \n": nil,
"y": nil,
"\n": errNotConfirmed,
"": errNotConfirmed,
"n\n": errNotConfirmed,
"yy\n": errNotConfirmed,
"no\ny\n": errNotConfirmed,
} {
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
t.Parallel()
cli := &Instance{terminal: strings.NewReader(answer)}
cmd, stderr := newConfirmTestCommand()
err := cli.confirm(cmd, "Remove it?")
require.ErrorIs(t, err, want)
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
})
}
}
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
// and removes what it names when the user answers y.
func TestRemovalAnsweredYesRemoves(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
requireExists(t, r.fs, r.removed, true)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader("y\n")
cmd, stderr := newConfirmTestCommand()
require.NoError(t, r.run(cli, cmd, false))
assert.Equal(t, r.question+" [y/N] ", stderr.String())
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalDeclinedLeavesEverything checks that each removal changes
// nothing when the user answers anything but y or yes, a bare Enter
// included.
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
for _, answer := range []string{"\n", "n\n", ""} {
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader(answer)
cmd, stderr := newConfirmTestCommand()
err := r.run(cli, cmd, false)
require.ErrorIs(t, err, errNotConfirmed)
assert.Equal(t, r.question+" [y/N] ", stderr.String())
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
}
// TestRemovalForcedAsksNothing checks that each removal with --force
// removes what it would have named without asking, and without reading
// its input, which is not a terminal.
func TestRemovalForcedAsksNothing(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
input := strings.NewReader("n\n")
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
require.NoError(t, r.run(cli, cmd, true))
assert.Empty(t, stderr.String(), "asked with --force")
assert.Equal(t, 2, input.Len(), "read its input with --force")
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
// --force, whose input is not a terminal, fails at once telling the user
// to pass --force, and changes nothing. The input is a pipe that nobody
// writes to or closes, so reading it would block for good.
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
input, inputWriter, err := os.Pipe()
require.NoError(t, err)
t.Cleanup(func() {
_ = inputWriter.Close()
_ = input.Close()
})
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
done := make(chan error, 1)
go func() { done <- r.run(cli, cmd, false) }()
select {
case err := <-done:
require.ErrorIs(t, err, errNoTerminal)
assert.Contains(t, err.Error(), "pass --force")
case <-time.After(lockWait):
// Closing the pipe ends the read, and frees the lock if
// the command holds it.
_ = inputWriter.Close()
t.Fatal("waited for an answer on input that is not a terminal")
}
assert.Empty(t, stderr.String(), "asked without a terminal")
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
// for its answer, another command can take the state directory lock and
// change the secret, and that the removal then removes nothing, since the
// secret is no longer what the question named.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
r := newRemoval(t, "rm")
answers, answerWriter := io.Pipe()
questions, questionWriter := io.Pipe()
// Closing the answers ends the read if the test fails while the
// command waits for one.
t.Cleanup(func() { _ = answerWriter.Close() })
rm := NewCLIInstanceWithStateDir(r.fs, testStateDir)
rm.terminal = answers
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(questionWriter)
done := make(chan error, 1)
go func() { done <- r.run(rm, cmd, false) }()
question, err := bufio.NewReader(questions).ReadString(']')
require.NoError(t, err)
require.Equal(t, r.question+" [y/N]", question)
// Adds a third version while rm waits for its answer.
add := NewCLIInstanceWithStateDir(r.fs, testStateDir)
add.Mnemonic = testMnemonicBuffer(t)
add.cmd = &cobra.Command{}
add.cmd.SetIn(strings.NewReader("newest"))
add.cmd.SetOut(io.Discard)
added := make(chan error, 1)
go func() { added <- add.AddSecret(confirmTestSecret, true) }()
select {
case err := <-added:
require.NoError(t, err)
case <-time.After(lockWait):
t.Fatal("secret add waited for the lock while secret rm asked")
}
_, err = answerWriter.Write([]byte("y\n"))
require.NoError(t, err)
select {
case err := <-done:
require.ErrorIs(t, err, errChangedWhileAsking)
case <-time.After(lockWait):
t.Fatal("secret rm did not finish once answered")
}
requireExists(t, r.fs, r.removed, true)
}
+416
View File
@@ -0,0 +1,416 @@
package cli_test
import (
"bytes"
"io"
"maps"
"os"
"os/exec"
"slices"
"strings"
"syscall"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"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
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
// a second time, or `secret vault create` with the name of an existing
// vault, replaced that vault's keys, so that none of its secrets could be
// decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker.
//
//nolint:paralleltest // the cases share cmd
func TestCreateExistingVaultChangesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
// newCLI returns an instance on fs given the mnemonic and the unlock
// passphrase, as from the environment
newCLI := func(fs afero.Fs) *cli.Instance {
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return c
}
// `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change.
fs := afero.NewMemMapFs()
c := newCLI(fs)
cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd))
require.NoError(t, c.CreateVault(cmd, "work"))
require.NoError(t, c.SelectVault(cmd, "default"))
vaults, err := vault.ListVaults(fs, testStateDir)
require.NoError(t, err)
require.Len(t, vaults, 2)
for _, name := range vaults {
value := memguard.NewBufferFromBytes([]byte("value"))
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
require.NoError(t, err)
}
before := snapshotStateDir(t, fs)
tests := []struct {
command string
run func(c *cli.Instance) error
}{
{
"init",
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create default",
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
},
{
"vault create work",
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before)
err := tt.run(newCLI(fs))
require.ErrorIs(t, err, vault.ErrVaultExists)
require.Equal(t, before, snapshotStateDir(t, fs))
})
}
// Every case left the state directory exactly as recorded in before, so
// reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow.
for _, name := range vaults {
vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x")
require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes())
value.Destroy()
require.True(t, unchanged, "vault %q kept its secret", name)
}
}
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
// `secret vault create` put the mnemonic into the process environment,
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
// leave neither in the environment.
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
t.Setenv(secret.EnvStateDir, t.TempDir())
run := func(args ...string) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// With no terminal to prompt on, this succeeds only if the command
// read both variables
_, err := cli.ExecuteCommandInProcess(args, "", nil)
require.NoError(t, err)
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
_, set := os.LookupEnv(name)
require.False(t, set, "%s is set after %v", name, args)
}
}
run("init")
run("vault", "create", "work")
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
// `secret vault create` stopped at the passphrase prompt left a vault with
// no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything.
//
//nolint:paralleltest // the cases share cmd
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
// An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`.
empty := afero.NewMemMapFs()
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
cmd := &cobra.Command{}
tests := []struct {
command string
fs afero.Fs
run func(c *cli.Instance) error
}{
{
"init",
empty,
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create work",
withDefault,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs)
// Given no unlock passphrase, both commands prompt for it, which
// fails because the tests do not run in a terminal.
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
c.Mnemonic = mnemonic
err := tt.run(c)
require.ErrorIs(t, err, secret.ErrPassphraseNotRead)
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 no 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, run in a new session, which has no 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"),
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
output, err := cmd.CombinedOutput()
require.Error(t, err)
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
"Error: failed to read mnemonic: there is no terminal to ask on. "+
"Please set the SB_SECRET_MNEMONIC environment variable or run "+
"interactively\n", string(output))
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was
// written left a vault with no unlocker, which neither command would then
// create again. After the prompt, each command changes the state directory
// only through vault.CreateVault. The test makes that call as the command
// does and records the state directory before each change it makes, and once
// after it returns: what a stop at that point leaves. Each must hold either
// no vault, and not name it current, or exactly the finished vault, which
// opens with the passphrase through its current unlocker. The command run
// again after a stop first takes the lock, which must delete what the stop
// left under a temporary name. Running the command is slow, so it runs once
// on each different state the lock leaves, and must create the vault there,
// or refuse the one there.
//
//nolint:paralleltest // commands on the in-memory filesystem share one lock
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
t.Run("init", func(t *testing.T) {
// From an empty state directory
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
requireStopsLeaveWholeVaultOrNone(t, fs, "default", mnemonic, passphrase,
func(c *cli.Instance) error { return c.Init(cmd) })
})
t.Run("vault create work", func(t *testing.T) {
// From a state directory holding the vault "default"
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
requireStopsLeaveWholeVaultOrNone(t, fs, "work", mnemonic, passphrase,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
})
}
// requireStopsLeaveWholeVaultOrNone checks, as
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
// command run, creating the vault name on fs with mnemonic and passphrase.
// Run again where the vault is there, the command must fail with
// vault.ErrVaultExists.
func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name string,
mnemonic, passphrase *memguard.LockedBuffer,
run func(c *cli.Instance) error,
) {
t.Helper()
var stops []map[string]string
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
testStateDir, name, mnemonic, passphrase)
require.NoError(t, err)
record()
vaultDir := testStateDir + "/vaults.d/" + name
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
finished := entriesUnder(stops[len(stops)-1], vaultDir)
opener := vault.NewVault(fs, testStateDir, name)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
// Each different state the command run again finds once it holds the lock
var locked []map[string]string
for i, stop := range stops {
if _, there := stop[vaultDir+"/"]; there {
require.Equal(t, finished, entriesUnder(stop, vaultDir),
"stop %d left a partial vault", i)
} else {
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
"stop %d made a missing vault current", i)
}
stopped := newFsFromSnapshot(t, stop)
release, err := vault.LockStateDir(stopped, testStateDir)
require.NoError(t, err)
release()
state := snapshotStateDir(t, stopped)
for path := range state {
require.NotContains(t, path, ".tmp-", "stop %d", i)
}
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
return maps.Equal(s, state)
}) {
locked = append(locked, state)
}
}
for _, state := range locked {
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
if _, there := state[vaultDir+"/"]; there {
require.ErrorIs(t, run(c), vault.ErrVaultExists)
} else {
require.NoError(t, run(c))
}
}
}
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
// that are under dir.
func entriesUnder(tree map[string]string, dir string) map[string]string {
entries := map[string]string{}
for path, content := range tree {
if strings.HasPrefix(path, dir+"/") {
entries[path] = content
}
}
return entries
}
// hookFs passes every call through to Fs, but first calls before for each
// call that can change the filesystem.
type hookFs struct {
afero.Fs
before func()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
h.before()
return h.Fs.Create(name)
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
h.before()
return h.Fs.OpenFile(name, flag, perm)
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
h.before()
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
h.before()
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
h.before()
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
h.before()
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
h.before()
return h.Fs.Rename(oldname, newname)
}
+314
View File
@@ -0,0 +1,314 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// Sentinel errors for encrypt/decrypt operations
var (
errNotAgeSecretKey = errors.New(
"does not contain a valid age secret key")
)
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
func newCryptoCmd(
use, short, long string,
run func(cli *Instance, secretName, inputFile, outputFile string) error,
) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Long: long,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputFile, _ := cmd.Flags().GetString("input")
outputFile, _ := cmd.Flags().GetString("output")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return run(cli, args[0], inputFile, outputFile)
},
}
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
return cmd
}
func newEncryptCmd() *cobra.Command {
return newCryptoCmd(
"encrypt <secret-name>",
"Encrypt data using an age secret key stored in a secret",
"Encrypt data using an age secret key. If the secret doesn't "+
"exist, a new age key is generated and stored.",
(*Instance).Encrypt,
)
}
func newDecryptCmd() *cobra.Command {
return newCryptoCmd(
"decrypt <secret-name>",
"Decrypt data using an age secret key stored in a secret",
"Decrypt data using an age secret key stored in the specified secret.",
(*Instance).Decrypt,
)
}
// storeNewEncryptionKey generates an age secret key and stores it as the
// named secret, holding the state directory lock while it does. It fails
// with vault.ErrSecretExists if another command stored the secret first.
// The caller must destroy the returned buffer.
func (cli *Instance) storeNewEncryptionKey(
vlt *vault.Vault, secretName string,
) (*memguard.LockedBuffer, error) {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return nil, err
}
defer release()
identity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age key: %w", err)
}
secureBuffer := secret.IdentityToLockedBuffer(identity)
err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil {
secureBuffer.Destroy()
return nil, fmt.Errorf("failed to store age key: %w", err)
}
return secureBuffer, nil
}
// resolveEncryptionKey returns a secure buffer holding the age secret key
// for the named secret, generating and storing a new key if the secret
// does not exist. The caller must destroy the returned buffer. Only storing
// a new key takes the state directory lock, so that reading an existing key
// works on a read-only state directory and keeps no other command waiting
// at the passphrase prompt, and Encrypt streams its input and output
// unlocked.
func (cli *Instance) resolveEncryptionKey(
vlt *vault.Vault, secretName string,
) (*memguard.LockedBuffer, error) {
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
exists, err := secretObj.Exists()
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
key, err := cli.storeNewEncryptionKey(vlt, secretName)
if !errors.Is(err, vault.ErrSecretExists) {
return key, err
}
// Another command stored the key since the check above: read it
}
// Secret exists, get the age secret key from it
secretBuffer, err := vlt.GetSecret(secretName)
if err != nil {
return nil, fmt.Errorf("failed to get secret value: %w", err)
}
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(secretBuffer.String()) {
secretBuffer.Destroy()
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
}
return secretBuffer, nil
}
// Encrypt encrypts data using an age secret key stored in a secret
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get or create the age secret key for this secret
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
if err != nil {
return err
}
defer keyBuffer.Destroy()
// Parse the secret key
identity, err := age.ParseX25519Identity(keyBuffer.String())
if err != nil {
return fmt.Errorf("failed to parse age secret key: %w", err)
}
// Get recipient from identity
recipient := identity.Recipient()
// Set up input reader
var input io.Reader = os.Stdin
if inputFile != "" {
file, err := cli.fs.Open(inputFile)
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer func() { _ = file.Close() }()
input = file
}
// Set up output writer
output := cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
output = file
}
// Encrypt the data
encryptor, err := age.Encrypt(output, recipient)
if err != nil {
return fmt.Errorf("failed to create age encryptor: %w", err)
}
_, err = io.Copy(encryptor, input)
if err != nil {
return fmt.Errorf("failed to encrypt data: %w", err)
}
err = encryptor.Close()
if err != nil {
return fmt.Errorf("failed to finalize encryption: %w", err)
}
return nil
}
// Decrypt decrypts data using an age secret key stored in a secret
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
exists, err := secretObj.Exists()
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
}
// Get the age secret key from the secret
secretBuffer, err := vlt.GetSecret(secretName)
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
}
defer secretBuffer.Destroy()
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(secretBuffer.String()) {
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
}
// Parse the age secret key to get the identity
identity, err := age.ParseX25519Identity(secretBuffer.String())
if err != nil {
return fmt.Errorf("failed to parse age secret key: %w", err)
}
// Set up input reader
var input io.Reader = os.Stdin
if inputFile != "" {
file, err := cli.fs.Open(inputFile)
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer func() { _ = file.Close() }()
input = file
}
// Set up output writer
output := cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
output = file
}
// Decrypt the data
decryptor, err := age.Decrypt(input, identity)
if err != nil {
return fmt.Errorf("failed to create age decryptor: %w", err)
}
_, err = io.Copy(output, decryptor)
if err != nil {
return fmt.Errorf("failed to decrypt data: %w", err)
}
return nil
}
// isValidAgeSecretKey checks if a string is a valid age secret key by
// attempting to parse it
func isValidAgeSecretKey(key string) bool {
_, err := age.ParseX25519Identity(key)
return err == nil
}
+107
View File
@@ -0,0 +1,107 @@
package cli_test
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"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
// memguard purge runs on the success and the error path alike.
//
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
savedArgs := os.Args
t.Cleanup(func() { os.Args = savedArgs })
tests := []struct {
args []string
exitCode int
}{
{args: []string{"secret", "--help"}, exitCode: 0},
{args: []string{"secret", "no-such-command"}, exitCode: 1},
}
for _, tt := range tests {
buf := memguard.NewBufferFromBytes([]byte("key material"))
os.Args = tt.args
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
}
}
// Ctrl-C while `secret add` waits for the value on stdin must end the
// process through memguard's signal handler, which wipes every buffer and
// exits with status 1, not through Go's default handling, which kills the
// process with the buffers intact.
func TestInterruptExitsThroughMemguard(t *testing.T) {
t.Parallel()
const waitingForValue = "Reading secret value from stdin"
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel()
wd, err := filepath.Abs("../..")
require.NoError(t, err)
secretPath := filepath.Join(wd, "secret")
env := []string{
secret.EnvStateDir + "=" + t.TempDir(),
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
// The debug log on stderr shows when add starts waiting for the value.
"GODEBUG=berlin.sneak.pkg.secret",
}
//nolint:gosec // G204: test executes the freshly built secret binary
initCmd := exec.CommandContext(ctx, secretPath, "init")
initCmd.Env = env
output, err := initCmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", output)
//nolint:gosec // G204: test executes the freshly built secret binary
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
addCmd.Env = env
// Held open and never written, so add keeps waiting for the value.
stdin, err := addCmd.StdinPipe()
require.NoError(t, err)
defer func() { _ = stdin.Close() }()
stderr, err := addCmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, addCmd.Start())
waiting := false
scanner := bufio.NewScanner(stderr)
for !waiting && scanner.Scan() {
waiting = strings.Contains(scanner.Text(), waitingForValue)
}
require.True(t, waiting, "add never logged %q", waitingForValue)
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
err = addCmd.Wait()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
}
+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)
})
}
}
+221
View File
@@ -0,0 +1,221 @@
package cli
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"os"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/vault"
)
const (
defaultSecretLength = 16
mnemonicEntropyBits = 128
)
// Sentinel errors for secret generation
var (
errLengthTooSmall = errors.New("length must be at least 1")
errUnsupportedSecretType = errors.New("unsupported type")
)
func newGenerateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
Short: "Generate random data",
Long: `Generate various types of random data including mnemonics and secrets.`,
}
cmd.AddCommand(newGenerateMnemonicCmd())
cmd.AddCommand(newGenerateSecretCmd())
return cmd
}
func newGenerateMnemonicCmd() *cobra.Command {
return &cobra.Command{
Use: "mnemonic",
Short: "Generate a random BIP39 mnemonic phrase",
Long: `Generate a cryptographically secure random BIP39 ` +
`mnemonic phrase that can be used with 'secret init' ` +
`or 'secret import'.`,
RunE: func(cmd *cobra.Command, _ []string) error {
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GenerateMnemonic(cmd)
},
}
}
func newGenerateSecretCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "secret <name>",
Short: "Generate a random secret and store it in the vault",
Long: `Generate a cryptographically secure random secret and ` +
`store it in the current vault under the given name.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
length, _ := cmd.Flags().GetInt("length")
secretType, _ := cmd.Flags().GetString("type")
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
},
}
cmd.Flags().IntP("length", "l", defaultSecretLength,
"Length of the generated secret (default 16)")
cmd.Flags().StringP("type", "t", "base58",
"Type of secret to generate (base58, alnum)")
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
return cmd
}
// GenerateMnemonic generates a random BIP39 mnemonic phrase
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
// Generate 128 bits of entropy for a 12-word mnemonic
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
if err != nil {
return fmt.Errorf("failed to generate entropy: %w", err)
}
// Create mnemonic from entropy
mnemonic, err := bip39.NewMnemonic(entropy)
if err != nil {
return fmt.Errorf("failed to generate mnemonic: %w", err)
}
// Output mnemonic to stdout
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
// Output helpful information to stderr
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "⚠️ IMPORTANT: Save this mnemonic phrase securely!")
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
fmt.Fprintln(os.Stderr,
" • If you lose this phrase, your secrets cannot be recovered")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)")
return nil
}
// GenerateSecret generates a random secret and stores it in the vault
func (cli *Instance) GenerateSecret(
cmd *cobra.Command,
secretName string,
length int,
secretType string,
force bool,
) error {
if length < 1 {
return errLengthTooSmall
}
var (
secretValue string
err error
)
switch secretType {
case "base58":
secretValue, err = generateRandomBase58(length)
case "alnum":
secretValue, err = generateRandomAlnum(length)
case "mnemonic":
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
errUnsupportedSecretType)
default:
return fmt.Errorf("%w: %s (supported: base58, alnum)",
errUnsupportedSecretType, secretType)
}
if err != nil {
return fmt.Errorf("failed to generate random secret: %w", err)
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Store the secret in the vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Protect the generated secret immediately
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
defer secretBuffer.Destroy()
err = vlt.AddSecret(secretName, secretBuffer, force)
if err != nil {
return err
}
cmd.Printf("Generated and stored %d-character %s secret: %s\n",
length, secretType, secretName)
return nil
}
// generateRandomBase58 generates a random base58 string of the specified length
func generateRandomBase58(length int) (string, error) {
const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
return generateRandomString(length, base58Chars)
}
// generateRandomAlnum generates a random alphanumeric string of the specified length
func generateRandomAlnum(length int) (string, error) {
const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
return generateRandomString(length, alnumChars)
}
// generateRandomString generates a random string of the specified length
// using the given character set
func generateRandomString(length int, charset string) (string, error) {
if length < 1 {
return "", errLengthTooSmall
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}
+175
View File
@@ -0,0 +1,175 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"log"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/fatih/color"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
)
// Version info - these are set at build time
var (
Version = "dev" //nolint:gochecknoglobals // Set at build time
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
)
// InfoOutput represents the system information for JSON output
type InfoOutput struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
Author string `json:"author"`
License string `json:"license"`
GoVersion string `json:"goVersion"`
DataDirectory string `json:"dataDirectory"`
CurrentVault string `json:"currentVault"`
NumVaults int `json:"numVaults"`
NumSecrets int `json:"numSecrets"`
TotalSize int64 `json:"totalSizeBytes"`
OldestSecret time.Time `json:"oldestSecret"`
LatestSecret time.Time `json:"latestSecret"`
}
// newInfoCmd returns the info command
func newInfoCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
var jsonOutput bool
cmd := &cobra.Command{
Use: "info",
Short: "Display system information",
Long: "Display information about the secret system including " +
"version, vault statistics, and storage usage",
RunE: func(cmd *cobra.Command, _ []string) error {
return cli.Info(cmd, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
return cmd
}
// Info displays system information
func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
info := InfoOutput{
Version: Version,
GitCommit: GitCommit,
Author: "Jeffrey Paul <sneak@sneak.berlin>",
License: "WTFPL",
GoVersion: runtime.Version(),
DataDirectory: cli.stateDir,
}
// Get current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err == nil {
info.CurrentVault = currentVault.Name
}
// Count vaults
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
if err == nil {
for _, entry := range vaultEntries {
if entry.IsDir() {
info.NumVaults++
}
}
}
// Gather statistics from all vaults
if info.NumVaults > 0 {
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
cli.fs, vaultsDir)
info.NumSecrets = totalSecrets
info.TotalSize = totalSize
if !oldestTime.IsZero() {
info.OldestSecret = oldestTime
}
if !latestTime.IsZero() {
info.LatestSecret = latestTime
}
}
if jsonOutput {
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
return encoder.Encode(info)
}
// Pretty print with colors and emoji
return prettyPrintInfo(cmd.OutOrStdout(), info)
}
// prettyPrintInfo formats and prints the info in a pretty format
func prettyPrintInfo(w io.Writer, info InfoOutput) error {
const separatorLength = 40
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
yellow := color.New(color.FgYellow)
magenta := color.New(color.FgMagenta)
_, _ = fmt.Fprintln(w)
_, _ = bold.Fprintln(w, "🔐 Secret System Information")
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
_, _ = fmt.Fprintf(w, "📦 Version: %s\n", green.Sprint(info.Version))
_, _ = fmt.Fprintf(w, "🔧 Git Commit: %s\n", cyan.Sprint(info.GitCommit))
_, _ = fmt.Fprintf(w, "👤 Author: %s\n", cyan.Sprint(info.Author))
_, _ = fmt.Fprintf(w, "📜 License: %s\n", cyan.Sprint(info.License))
_, _ = fmt.Fprintf(w, "🐹 Go Version: %s\n", cyan.Sprint(info.GoVersion))
_, _ = fmt.Fprintf(w, "📁 Data Directory: %s\n", yellow.Sprint(info.DataDirectory))
if info.CurrentVault != "" {
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", magenta.Sprint(info.CurrentVault))
} else {
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", color.RedString("(none)"))
}
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
if info.TotalSize >= 0 {
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
} else {
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
}
if !info.OldestSecret.IsZero() {
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n",
info.OldestSecret.Format("2006-01-02 15:04:05"))
}
if !info.LatestSecret.IsZero() {
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n",
info.LatestSecret.Format("2006-01-02 15:04:05"))
}
_, _ = fmt.Fprintln(w)
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package cli
import (
"path/filepath"
"time"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
)
// vaultStats accumulates statistics while walking vault directories
type vaultStats struct {
totalSecrets int
totalSize int64
oldestTime time.Time
latestTime time.Time
}
// addVersion accumulates size and timestamp info for one version directory
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
// Add size of encrypted data
dataPath := filepath.Join(versionPath, "data.age")
stat, err := fs.Stat(dataPath)
if err == nil {
s.totalSize += stat.Size()
}
// Add size of metadata
metaPath := filepath.Join(versionPath, "metadata.age")
stat, err = fs.Stat(metaPath)
if err == nil {
s.totalSize += stat.Size()
}
// Track timestamps
stat, err = fs.Stat(versionPath)
if err == nil {
modTime := stat.ModTime()
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
s.oldestTime = modTime
}
if s.latestTime.IsZero() || modTime.After(s.latestTime) {
s.latestTime = modTime
}
}
}
// addSecret accumulates stats for one secret directory
func (s *vaultStats) addSecret(fs afero.Fs, secretsPath, secretName string) {
s.totalSecrets++
secretPath := filepath.Join(secretsPath, secretName)
// Get size and timestamps from all versions
versionsPath := filepath.Join(secretPath, "versions")
versionEntries, err := afero.ReadDir(fs, versionsPath)
if err != nil {
secret.Warn("Could not read versions directory for secret",
"secret", secretName, "error", err)
return
}
for _, versionEntry := range versionEntries {
if !versionEntry.IsDir() {
continue
}
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
}
}
// addVault accumulates stats for one vault directory
func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
vaultPath := filepath.Join(vaultsDir, vaultName)
secretsPath := filepath.Join(vaultPath, "secrets.d")
// Count secrets in this vault
secretEntries, err := afero.ReadDir(fs, secretsPath)
if err != nil {
secret.Warn("Could not read secrets directory for vault",
"vault", vaultName, "error", err)
return
}
for _, secretEntry := range secretEntries {
if !secretEntry.IsDir() {
continue
}
s.addSecret(fs, secretsPath, secretEntry.Name())
}
}
// gatherVaultStats collects statistics from all vaults, returning the
// total secret count, total size, and oldest/latest secret timestamps
func gatherVaultStats(
fs afero.Fs,
vaultsDir string,
) (int, int64, time.Time, time.Time, error) {
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return 0, 0, time.Time{}, time.Time{}, err
}
var stats vaultStats
for _, vaultEntry := range vaultEntries {
if !vaultEntry.IsDir() {
continue
}
stats.addVault(fs, vaultsDir, vaultEntry.Name())
}
return stats.totalSecrets, stats.totalSize,
stats.oldestTime, stats.latestTime, nil
}
+197
View File
@@ -0,0 +1,197 @@
package cli
import (
"errors"
"fmt"
"log"
"log/slog"
"strings"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// errPassphraseMismatch is returned when passphrase confirmation fails
var errPassphraseMismatch = errors.New("passphrases do not match")
// NewInitCmd creates the init command
func NewInitCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Initialize the secrets manager",
Long: `Create the necessary directory structure for storing ` +
`secrets and generate encryption keys.`,
RunE: RunInit,
}
}
// RunInit is the exported function that handles the init command
func RunInit(cmd *cobra.Command, _ []string) error {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd)
}
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// or reads it interactively. The returned cleanup function must be deferred
// by the caller.
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable")
return cli.Mnemonic, func() {}, nil
}
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic", "error", err)
return nil, nil, err
}
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// Init initializes the secret manager, holding the state directory lock
// while initialize runs
func (cli *Instance) Init(cmd *cobra.Command) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.initialize(cmd)
}
// initialize creates the state directory, the default vault and its first
// unlocker
func (cli *Instance) initialize(cmd *cobra.Command) error {
secret.Debug("Starting secret manager initialization")
// Create state directory
stateDir := cli.GetStateDir()
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
if err != nil {
secret.Debug("Failed to create state directory", "error", err)
return fmt.Errorf("failed to create state directory: %w", err)
}
if cmd != nil {
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
}
// Prompt for mnemonic
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
secret.Debug("Empty mnemonic provided")
return errMnemonicEmpty
}
// Validate the mnemonic using BIP39
secret.DebugWith("Validating BIP39 mnemonic",
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
if !bip39.IsMnemonicValid(mnemonicStr) {
secret.Debug("Invalid BIP39 mnemonic provided")
return fmt.Errorf(
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
errInvalidMnemonicPhrase)
}
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer cleanupPassphrase()
// Create the default vault with its passphrase unlocker
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
mnemonic, passphraseBuffer)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", err)
}
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return err
}
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
cmd.Println("\nYour secret manager is ready to use!")
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
cmd.Println("unlockers are not required for secret operations.")
}
return nil
}
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
// This version adds confirmation (read twice) for creating new unlockers
// Returns a LockedBuffer containing the passphrase
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
// Get the first passphrase
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
if err != nil {
return nil, err
}
// Read confirmation passphrase
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
if err != nil {
passphraseBuffer1.Destroy()
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
}
// Compare passphrases
if passphraseBuffer1.String() != passphraseBuffer2.String() {
passphraseBuffer1.Destroy()
passphraseBuffer2.Destroy()
return nil, errPassphraseMismatch
}
// Clean up the second buffer, we'll return the first
passphraseBuffer2.Destroy()
// Return the first buffer (caller is responsible for destroying it)
return passphraseBuffer1, nil
}
+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)
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
package cli_test
import (
"io"
"testing"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"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
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
// killed part-way leaves in each directory where secret.TempDirFor and
// secret.WriteFileAtomic make temporary entries: a temporary directory
// holding a vault, secret, unlocker or version being added or removed, and
// a temporary file beside a file being replaced. `secret list` must leave
// them all, and the next command that takes the state directory lock, here
// `secret vault select` of the vault already current, must delete exactly
// them: a vault named like a temporary directory stays. The copy has no
// lock file yet, so that command, as after a killed one, finds no mark that
// the last holder of the lock finished.
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
require.NoError(t, err)
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
before := snapshotStateDir(t, fs)
vaultDir := testStateDir + "/vaults.d/default"
secretDir := vaultDir + "/secrets.d/x"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 1)
for _, dir := range []string{
testStateDir + "/.tmp-1/default",
vaultDir + "/.tmp-2/x",
secretDir + "/.tmp-3/" + testVersion,
} {
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
require.NoError(t, afero.WriteFile(fs, dir+"/value.age",
[]byte("encrypted"), secret.FilePerms))
}
for _, file := range []string{
testStateDir + "/.currentvault.tmp-4",
vaultDir + "/.current-unlocker.tmp-5",
secretDir + "/.current.tmp-6",
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
} {
require.NoError(t, afero.WriteFile(fs, file,
[]byte("partial"), secret.FilePerms))
}
planted := snapshotStateDir(t, fs)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
require.NoError(t, c.ListSecrets(cmd, false, false, ""))
require.Equal(t, planted, snapshotStateDir(t, fs))
require.NoError(t, c.SelectVault(cmd, "default"))
require.Equal(t, before, snapshotStateDir(t, fs))
}
+570
View File
@@ -0,0 +1,570 @@
//nolint:testpackage // sets the unexported fields of Instance
package cli
import (
"bytes"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"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/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// lockWait is how long a test waits for something that must happen
// once the lock is free.
lockWait = 10 * time.Second
// testPassphrase protects the passphrase unlockers the tests create.
testPassphrase = "test-passphrase"
// testInput is a file outside the state directory that commands read.
testInput = "/input"
)
// lockInBackground starts taking the state directory lock and returns a
// channel that delivers the function releasing it once it has been taken.
func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
t.Helper()
taken := make(chan func(), 1)
go func() {
release, err := vault.LockStateDir(fs, testStateDir)
if assert.NoError(t, err) {
taken <- release
}
}()
return taken
}
// addAtOnce runs one add of the secret name per value, all at once, and
// returns their errors. Each add is given mnemonic, which a forced add
// needs.
func addAtOnce(
fs afero.Fs, stateDir, name string, force bool, values []string,
mnemonic *memguard.LockedBuffer,
) []error {
errs := make(chan error, len(values))
for _, value := range values {
go func() {
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = mnemonic
cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader(value))
errs <- cli.AddSecret(name, force)
}()
}
results := make([]error, 0, len(values))
for range values {
results = append(results, <-errs)
}
return results
}
// numbered returns count distinct values starting with prefix.
func numbered(prefix string, count int) []string {
values := make([]string, 0, count)
for i := range count {
values = append(values, prefix+"-"+strconv.Itoa(i))
}
return values
}
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
// the in-memory and on the real filesystem. Without the state directory
// lock, adds of a new secret all find it absent and replace each other, and
// forced adds read the same highest version number and overwrite each
// other's version. With it they behave as if run one after another.
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Parallel()
mnemonic := testMnemonicBuffer(t)
const adds = 8
for _, tc := range []struct {
name string
fs afero.Fs
stateDir string
}{
{"memory", afero.NewMemMapFs(), testStateDir},
{"real", afero.NewOsFs(), t.TempDir()},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
require.NoError(t, err)
// One add creates the secret; the others find that it exists
created := 0
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
numbered("create", adds), mnemonic) {
if err == nil {
created++
} else {
require.ErrorIs(t, err, vault.ErrSecretExists)
}
}
require.Equal(t, 1, created, "exactly one add creates the secret")
// Every forced add stores a version of its own
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
numbered("force", adds), mnemonic) {
require.NoError(t, err)
}
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
require.NoError(t, err)
vlt.Mnemonic = mnemonic
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(tc.fs,
filepath.Join(vaultDir, "secrets.d", "shared"))
require.NoError(t, err)
require.Len(t, versions, adds+1, "one version per successful add")
values := make(map[string]bool, len(versions))
for _, version := range versions {
value, err := vlt.GetSecretVersion("shared", version)
require.NoError(t, err)
values[string(value.Bytes())] = true
value.Destroy()
}
assert.Len(t, values, adds+1, "every add stored its own value")
})
}
}
// readNotifier passes reads through to Reader and closes reading at the
// first one.
type readNotifier struct {
io.Reader
reading chan struct{}
once sync.Once
}
func (r *readNotifier) Read(p []byte) (int, error) {
r.once.Do(func() { close(r.reading) })
return r.Reader.Read(p)
}
// TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in
// one process, starting encrypt once add is reading its input. Had add
// taken the state directory lock before reading, it would hold the lock
// while waiting for encrypt's output, and encrypt would wait for the lock
// to store its key: neither would finish.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) {
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
pipeReader, pipeWriter := io.Pipe()
// If the test gives up, this makes add's read fail, so that both
// commands return and release the lock the other tests use
t.Cleanup(func() { _ = pipeReader.Close() })
const commands = 2
input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})}
results := make(chan error, commands)
go func() {
add := NewCLIInstanceWithStateDir(fs, testStateDir)
add.cmd = &cobra.Command{}
add.cmd.SetIn(input)
results <- add.AddSecret("encrypted", false)
}()
go func() {
<-input.reading
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(pipeWriter)
err := encrypt.Encrypt("key", testInput, "")
// Ends add's input, as the end of the pipe does
_ = pipeWriter.CloseWithError(err)
results <- err
}()
timeout := time.After(lockWait)
for range commands {
select {
case err := <-results:
require.NoError(t, err)
case <-timeout:
t.Fatal("secret encrypt piped into secret add never finished")
}
}
}
// TestFailedCommandReleasesLock checks that a command failing after it
// took the state directory lock leaves the lock free for the next command.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestFailedCommandReleasesLock(t *testing.T) {
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
// Fails once it holds the lock: there is no current vault. Without
// --force it would fail before taking the lock, on the check it makes
// before asking.
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
require.Error(t, err)
select {
case release := <-lockInBackground(t, fs):
release()
case <-time.After(lockWait):
t.Fatal("the failed command left the state directory locked")
}
}
// stateDirModTimes returns the modification time of every file and
// directory under the test state directory. Any change a command makes, even
// rewriting a file with the same content, changes it.
func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
t.Helper()
modTimes := make(map[string]int64)
err := afero.Walk(fs, testStateDir,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
modTimes[path] = info.ModTime().UnixNano()
return nil
})
require.NoError(t, err)
return modTimes
}
// setupEveryCommand makes what each command in
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
// versions of "test/secret", the vault "other" without a long-term key, for
// vault import, and the file testInput. There is no vault "default", which
// init creates. If withUnlocker is set, it also gives "work" a passphrase
// unlocker, which is slow. It returns the older version and the unlocker's
// ID.
func setupEveryCommand(
t *testing.T, fs afero.Fs, withUnlocker bool,
) (string, string) {
t.Helper()
mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err)
otherDir, err := other.GetDirectory()
require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
addTestSecret(t, vlt, []byte("newer"), true)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 2)
unlockerID := ""
if withUnlocker {
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
unlockerID = unlocker.GetID()
}
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
// Newest first
return versions[1], unlockerID
}
// waitingForLock reports whether a goroutine is stopped in
// vault.LockStateDir, waiting for the in-memory filesystem's lock. The
// stack trace of such a goroutine starts with the reason it waits,
// "[sync.Mutex.Lock]", and names LockStateDir.
func waitingForLock() bool {
stacks := make([]byte, 1<<20)
stacks = stacks[:runtime.Stack(stacks, true)]
for goroutine := range bytes.SplitSeq(stacks, []byte("\n\n")) {
if bytes.Contains(goroutine, []byte("[sync.Mutex.Lock")) &&
bytes.Contains(goroutine, []byte("vault.LockStateDir(")) {
return true
}
}
return false
}
// requireWaitsForLock runs a command, given what setupEveryCommand made,
// while holding the state directory lock. The command must neither finish
// nor change anything before it waits for the lock, and must succeed once
// the lock is released.
func requireWaitsForLock(
t *testing.T,
withUnlocker bool,
run func(cli *Instance, olderVersion, unlockerID string) error,
) {
t.Helper()
fs := afero.NewMemMapFs()
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
release, err := vault.LockStateDir(fs, testStateDir)
require.NoError(t, err)
// Released at most once, and also if the test fails while holding it,
// so that later tests can take it
release = sync.OnceFunc(release)
defer release()
// Taken only now, since taking the lock writes the lock file.
before := stateDirModTimes(t, fs)
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer unlockPassphrase.Destroy()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
cli.UnlockPassphrase = unlockPassphrase
cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader("value"))
cli.cmd.SetOut(io.Discard)
done := make(chan error, 1)
go func() { done <- run(cli, olderVersion, unlockerID) }()
timeout := time.After(lockWait)
for !waitingForLock() {
select {
case err := <-done:
t.Fatalf("finished while the lock was held, with error %v", err)
case <-timeout:
t.Fatal("never waited for the lock")
case <-time.After(time.Millisecond):
}
}
assert.Equal(t, before, stateDirModTimes(t, fs),
"changed the state directory before waiting for the lock")
release()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(lockWait):
t.Fatal("did not finish once the lock was released")
}
}
// TestChangingCommandsWaitForLock checks that each command that changes the
// state directory waits for its lock.
//
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
func TestChangingCommandsWaitForLock(t *testing.T) {
for _, tc := range []struct {
name string
withUnlocker bool
run func(cli *Instance, olderVersion, unlockerID string) error
}{
{"add", false, func(cli *Instance, _, _ string) error {
return cli.AddSecret("added", false)
}},
{"import", false, func(cli *Instance, _, _ string) error {
return cli.ImportSecret(cli.cmd, "imported", testInput, false)
}},
{"generate secret", false, func(cli *Instance, _, _ string) error {
return cli.GenerateSecret(cli.cmd, "generated", 16, "base58", false)
}},
{"encrypt", false, func(cli *Instance, _, _ string) error {
return cli.Encrypt("key", testInput, "")
}},
{"rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveSecret(cli.cmd, "test/secret", true)
}},
{"move", false, func(cli *Instance, _, _ string) error {
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
}},
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
}},
{"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
}},
{"vault create", false, func(cli *Instance, _, _ string) error {
return cli.CreateVault(cli.cmd, "created")
}},
{"vault select", false, func(cli *Instance, _, _ string) error {
return cli.SelectVault(cli.cmd, "other")
}},
{"vault import", false, func(cli *Instance, _, _ string) error {
return cli.VaultImport(cli.cmd, "other")
}},
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveVault(cli.cmd, "other", true)
}},
{"unlocker add", false, func(cli *Instance, _, _ string) error {
return cli.UnlockersAdd("passphrase", cli.cmd)
}},
{"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
}},
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
return cli.UnlockerSelect(unlockerID)
}},
{"init", false, func(cli *Instance, _, _ string) error {
return cli.Init(cli.cmd)
}},
} {
t.Run(tc.name, func(t *testing.T) {
requireWaitsForLock(t, tc.withUnlocker, tc.run)
})
}
}
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
// key that already exists, which only reads the state directory, finishes
// while another command holds the state directory lock.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.Mnemonic = mnemonic
encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(io.Discard)
// Stores the key
require.NoError(t, encrypt.Encrypt("key", testInput, ""))
release, err := vault.LockStateDir(fs, testStateDir)
require.NoError(t, err)
// Also frees a waiting encrypt if the test fails, so that it releases
// the lock the other tests use
defer release()
done := make(chan error, 1)
go func() { done <- encrypt.Encrypt("key", testInput, "") }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(lockWait):
t.Fatal("secret encrypt with an existing key waited for the lock")
}
}
// TestEncryptStreamsUnlocked checks that secret encrypt has released the
// state directory lock by the time it writes its output. Holding it while
// streaming would stall every other changing command for as long as the
// stream lasts, and forever when the other end of the pipe is one of them.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) {
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
outputReader, outputWriter := io.Pipe()
done := make(chan error, 1)
go func() {
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(outputWriter)
err := encrypt.Encrypt("key", testInput, "")
_ = outputWriter.CloseWithError(err)
done <- err
}()
// The first byte of output: encrypt is streaming now, and blocked
// writing until it is read
_, err = io.ReadFull(outputReader, make([]byte, 1))
require.NoError(t, err)
taken := lockInBackground(t, fs)
select {
case release := <-taken:
release()
case <-time.After(lockWait):
// Let encrypt finish, so that it releases the lock, then free it
// again for the tests that follow
_, _ = io.Copy(io.Discard, outputReader)
(<-taken)()
t.Fatal("secret encrypt held the lock while streaming")
}
_, err = io.Copy(io.Discard, outputReader)
require.NoError(t, err)
require.NoError(t, <-done)
}
+260
View File
@@ -0,0 +1,260 @@
package cli_test
import (
"os"
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
)
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
// onto itself deleted it, also when "work" was spelled two ways, and a failed
// move within "work" left "work" the current vault. "default" is the current
// vault in every case, and each case runs on its own copy of the state
// directory.
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"])
const (
ontoItself = "secret 'x' cannot be moved onto itself"
workX = "work:x"
)
// internal/cli declares these errors itself and does not export them, so
// only their text can be compared.
tests := []struct {
command string
source, dest string
force bool
wantErr string
}{
{"mv x x", "x", "x", false, ontoItself},
{"mv --force x x", "x", "x", true, ontoItself},
{"mv --force work:x work:", workX, "work:", true, ontoItself},
// An empty destination name defaults to the source name.
{`mv --force work:x ""`, workX, "", true, ontoItself},
// "work" is a vault name, so the destination is work:x.
{"mv --force work:x work", workX, "work", true, ontoItself},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
require.Equal(t, before, snapshotStateDir(t, fs))
require.EqualError(t, err, tt.wantErr)
})
}
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
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault.
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
require.NoError(t, err)
after := snapshotStateDir(t, fs)
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
require.Equal(t, "default", after[testStateDir+"/currentvault"])
require.Contains(t, after, workSecrets+"y/")
require.NotContains(t, after, workSecrets+"x/")
}
// TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for
// https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive
// filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo`
// removed the destination, which was the source. Symbolic links on the real
// filesystem give one secret two names here: in "default", "y" is a link to
// the secret "x", and the secrets.d of "other" is a link to that of
// "default", so other:x is default:x. Each move must be rejected and leave
// the secret and the links as they were.
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Parallel()
const isSame = "is the same secret on this filesystem"
tests := []struct {
command string
source, dest string
force bool
wantErr string
}{
{
"mv --force y x", "y", "x", true,
"secret 'y' cannot be moved onto itself: 'x' " + isSame,
},
{
"mv --force x y", "x", "y", true,
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
},
{
"mv x y", "x", "y", false,
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
},
{
"mv --force default:x other:x", "default:x", "other:x", true,
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
isSame,
},
{
"mv default:x other", "default:x", "other", false,
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
isSame,
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
require.NoError(t, err)
defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d")
otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d")
link := filepath.Join(defaultSecrets, "y")
require.NoError(t, os.Symlink("x", link))
require.NoError(t, os.Remove(otherSecrets))
require.NoError(t, os.Symlink(defaultSecrets, otherSecrets))
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
value, err := vlt.GetSecret("x")
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, []byte("value"), value.Bytes())
target, err := os.Readlink(link)
require.NoError(t, err)
require.Equal(t, "x", target)
target, err = os.Readlink(otherSecrets)
require.NoError(t, err)
require.Equal(t, defaultSecrets, target)
require.EqualError(t, moveErr, tt.wantErr)
})
}
}
// TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo"
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
// with "Foo".
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo"))
if err == nil {
t.Skip("the temporary directory is on a case-insensitive filesystem")
}
err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false)
require.NoError(t, err)
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true)
require.NoError(t, err)
value, err := vlt.GetSecret("foo")
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, []byte("upper"), value.Bytes())
_, err = vlt.GetSecret("Foo")
require.ErrorIs(t, err, vault.ErrSecretNotFound)
}
+204
View File
@@ -0,0 +1,204 @@
package cli_test
import (
"bufio"
"bytes"
"context"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/creack/pty"
"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"
)
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
// the passphrase failed instead of asking for it. Each command here must ask
// on the terminal and succeed once the passphrase is typed there.
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
testMnemonicBuffer(t), passphrase)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("v1"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("s1", value, false))
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
key := secret.IdentityToLockedBuffer(identity)
defer key.Destroy()
require.NoError(t, vlt.AddSecret("mykey", key, false))
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
vlt.UnlockPassphrase = passphrase
added, err := vlt.GetSecret("s1")
require.NoError(t, err)
defer added.Destroy()
assert.Equal(t, "v2", added.String())
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
assert.Equal(t, "a document\n", decrypted)
}
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
// terminal: the terminal is its controlling terminal and none of stdin,
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
// ends its line there, so stderr must not get a newline for it as well, a
// blank line in the log. pty.Open is described above
// TestRemoveIgnoresTerminalOnStdout.
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel()
stateDir := t.TempDir()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
secret.EnvUnlockPassphrase + "=" + testPassphrase,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
deadline, _ := ctx.Deadline()
require.NoError(t, ptmx.SetReadDeadline(deadline))
var stderr strings.Builder
cmd.Stderr = &stderr
cmd.ExtraFiles = []*os.File{tty}
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
require.NoError(t, cmd.Start())
_ = tty.Close()
var (
shown []byte
char byte
)
terminal := bufio.NewReader(ptmx)
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
char, err = terminal.ReadByte()
require.NoError(t, err, "secret init did not ask on the terminal: %s",
shown)
shown = append(shown, char)
}
_, err = ptmx.WriteString(testMnemonic + "\n")
require.NoError(t, err)
// The read ends once the command has exited and so closed the terminal.
rest, _ := io.ReadAll(terminal)
err = cmd.Wait()
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
assert.True(t, strings.HasPrefix(stderr.String(),
"Initialized secrets manager at: "+stateDir+"\n\n"+
"Default vault created and configured\n"),
"stderr: %q", stderr.String())
}
// runAtTerminal runs the secret binary with args and input on stdin, in a
// new session whose controlling terminal is a new terminal, which is also
// its stderr: a command typed at a terminal with its input piped. pty.Open
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
// for the passphrase prompt on the terminal, types the passphrase there,
// and returns what the command wrote to stdout once it has succeeded.
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
deadline, _ := ctx.Deadline()
require.NoError(t, ptmx.SetReadDeadline(deadline))
var stdout strings.Builder
cmd.Stdin = strings.NewReader(input)
cmd.Stdout = &stdout
cmd.Stderr = tty
// Ctty is a descriptor in the new process: 2, its stderr.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
require.NoError(t, cmd.Start())
_ = tty.Close()
var (
shown []byte
char byte
)
terminal := bufio.NewReader(ptmx)
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
char, err = terminal.ReadByte()
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
args, shown)
shown = append(shown, char)
}
_, err = ptmx.WriteString(testPassphrase + "\n")
require.NoError(t, err)
// The read ends once the command has exited and so closed the terminal.
rest, _ := io.ReadAll(terminal)
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
return stdout.String()
}
+439
View File
@@ -0,0 +1,439 @@
package cli_test
import (
"fmt"
"maps"
"os"
"slices"
"strings"
"sync"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// testStateDir is the in-memory state directory of the test vaults.
testStateDir = "/test/state"
// testPassphrase protects the passphrase unlocker of each test vault.
testPassphrase = "test-passphrase"
// testVersion is a version name in the format the vault uses.
testVersion = "20260101.001"
// missingFile is an import source that does not exist, so an import
// that opened it before checking the name would fail with another error.
missingFile = "/no/such/file"
)
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
// Creating a passphrase unlocker is slow by design, so the vaults are made
// once, by the first test that needs them.
//
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
var (
twoVaultsOnce sync.Once
twoVaults map[string]string
)
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
// and "default", the current one. Each holds the secret "x" and a
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
// Every call returns a new copy of the same vaults.
//
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
func newTwoVaultFs(t *testing.T) afero.Fs {
t.Helper()
twoVaultsOnce.Do(func() {
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
require.NoError(t, err)
_, err = vlt.CreatePassphraseUnlocker(
memguard.NewBufferFromBytes([]byte(testPassphrase)))
require.NoError(t, err)
}
twoVaults = snapshotStateDir(t, fs)
})
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
return newFsFromSnapshot(t, twoVaults)
}
// snapshotStateDir maps every file under the state directory to its
// contents, and every directory, written with a trailing "/", to "". Two
// snapshots are equal only if nothing in it was added, removed or changed.
// The lock file, which every command that takes the lock writes, is left
// out.
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
t.Helper()
tree := map[string]string{}
err := afero.Walk(fs, testStateDir, func(
path string, info os.FileInfo, err error,
) error {
if err != nil {
return err
}
if path == testStateDir+"/lock" {
return nil
}
if info.IsDir() {
tree[path+"/"] = ""
return nil
}
content, err := afero.ReadFile(fs, path)
if err != nil {
return err
}
tree[path] = string(content)
return nil
})
require.NoError(t, err)
return tree
}
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
// directories and files recorded by snapshotStateDir.
//
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
t.Helper()
fs := afero.NewMemMapFs()
// In sorted order every directory comes before its contents.
for _, path := range slices.Sorted(maps.Keys(tree)) {
dir, isDir := strings.CutSuffix(path, "/")
if isDir {
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
continue
}
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
require.NoError(t, err)
}
return fs
}
// requireRejectedAndUnchanged runs a command on a copy of the state
// directory recorded in before. It requires the error want, so that a later
// check rejecting the argument does not count, and everything under the
// state directory as it was: the error alone proves nothing, since it could
// come after the vault had already been deleted.
func requireRejectedAndUnchanged(
t *testing.T, before map[string]string, want error,
run func(c *cli.Instance) error,
) {
t.Helper()
fs := newFsFromSnapshot(t, before)
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
require.Equal(t, before, snapshotStateDir(t, fs))
require.ErrorIs(t, err, want)
}
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
// Removals, moves and imports use --force, so that only the name check
// stands in the way.
//
//nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// Creating a passphrase unlocker is slow by design, so the vaults are
// created once and each case runs on its own copy of them.
before := snapshotStateDir(t, newTwoVaultFs(t))
vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{}
tests := []struct {
command string
run func(c *cli.Instance) error
}{
{"rm --force ..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", true)
}},
{"rm --force .", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", true)
}},
{`rm --force ""`, func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", true)
}},
{"rm --force ../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", true)
}},
{"mv --force .. x", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "..", "x", true)
}},
{"mv --force x ..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "x", "..", true)
}},
{`mv --force x ""`, func(c *cli.Instance) error {
return c.MoveSecret(cmd, "x", "", true)
}},
// "work" is not the current vault: a move within it must not
// select it when a name is rejected.
{"mv --force work:.. work:x", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:..", "work:x", true)
}},
{"mv --force work:x work:..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:x", "work:..", true)
}},
{"mv --force default:.. work", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work", true)
}},
{"mv --force default:.. work:y", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work:y", true)
}},
{"mv --force default:x work:..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:x", "work:..", true)
}},
{"import --force ..", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "..", missingFile, true)
}},
{"import --force .", func(c *cli.Instance) error {
return c.ImportSecret(cmd, ".", missingFile, true)
}},
{"import --force ../../etc", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "../../etc", missingFile, true)
}},
{"version list ..", func(c *cli.Instance) error {
return c.ListVersions(cmd, "..")
}},
{"version promote ..", func(c *cli.Instance) error {
return c.PromoteVersion(cmd, "..", testVersion)
}},
{"version rm --force ..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion, true)
}},
{"encrypt ..", func(c *cli.Instance) error {
return c.Encrypt("..", "", "")
}},
{"decrypt ..", func(c *cli.Instance) error {
return c.Decrypt("..", "", "")
}},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrInvalidSecretName, tt.run)
})
}
}
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/67, where
// `secret version rm x ../../..` deleted the whole vault,
// `secret version rm x ..` the secret x, and `secret version rm x .` or
// `secret version rm x ""` every version of x. A version argument is
// accepted only if it is one of the versions `secret version list` lists.
//
//nolint:paralleltest // the cases share cmd
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
cmd := &cobra.Command{}
commands := []struct {
command string
run func(c *cli.Instance, version string) error
}{
{"version rm --force x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version, true)
}},
{"version promote x", func(c *cli.Instance, version string) error {
return c.PromoteVersion(cmd, "x", version)
}},
{"get x --version", func(c *cli.Instance, version string) error {
return c.GetSecretWithVersion(cmd, "x", version)
}},
}
for _, tt := range commands {
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound,
func(c *cli.Instance) error { return tt.run(c, version) })
})
}
}
}
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/68, where
// `secret vault import ..` wrote a long-term key and an unlocker into the
// state directory itself, and `secret vault select ..` made it the current
// vault. Each command that takes a vault name must reject an invalid one
// before building a path from it. The instance is given the mnemonic and
// the passphrase, and moves and removals use --force, so that only the name
// check stands in the way.
//
//nolint:paralleltest // the cases share cmd
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
// Each command is a format with %q where the vault name goes.
commands := []struct {
command string
run func(c *cli.Instance, name string) error
}{
{"vault create %q", func(c *cli.Instance, name string) error {
return c.CreateVault(cmd, name)
}},
{"vault import %q", func(c *cli.Instance, name string) error {
return c.VaultImport(cmd, name)
}},
{"vault select %q", func(c *cli.Instance, name string) error {
return c.SelectVault(cmd, name)
}},
{"vault remove --force %q", func(c *cli.Instance, name string) error {
return c.RemoveVault(cmd, name, true)
}},
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
return c.MoveSecret(cmd, name+":x", "work:x", true)
}},
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
return c.MoveSecret(cmd, "default:x", name+":x", true)
}},
}
for _, tt := range commands {
for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
func(c *cli.Instance) error {
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return tt.run(c, name)
})
})
}
}
}
// TestRemoveVersionRemovesOnlyThatVersion checks that
// `secret version rm --force` with a version that is not the current one
// removes that version and changes nothing else.
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
// A second version of "x" becomes the current one.
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
require.NoError(t, err)
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// ListVersions lists the newest version first.
oldDir := secretDir + "/versions/" + versions[1] + "/"
before := snapshotStateDir(t, fs)
require.Contains(t, before, oldDir)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
require.NoError(t, err)
// Expected: the state as before without everything under oldDir.
want := map[string]string{}
for path, content := range before {
if !strings.HasPrefix(path, oldDir) {
want[path] = content
}
}
require.Equal(t, want, snapshotStateDir(t, fs))
}
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
// where "work" is also the name of a vault, renames the secret "x" to "work"
// in the current vault and changes nothing else.
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, "x", "work", false)
require.NoError(t, err)
// Expected: the state as before, with everything under the current
// vault's secrets.d/x/ now under secrets.d/work/.
oldDir := testStateDir + "/vaults.d/default/secrets.d/x/"
newDir := testStateDir + "/vaults.d/default/secrets.d/work/"
want := map[string]string{}
for path, content := range before {
rest, found := strings.CutPrefix(path, oldDir)
if found {
path = newDir + rest
}
want[path] = content
}
require.Contains(t, want, newDir)
require.Equal(t, want, snapshotStateDir(t, fs))
}
+109
View File
@@ -0,0 +1,109 @@
package cli
import (
"os"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
"golang.org/x/term"
"sneak.berlin/go/secret/internal/secret"
)
// Entry runs the secret CLI and returns the process exit code. It wipes
// every memguard buffer before it returns, so the caller must do nothing
// but exit with the code.
func Entry() int {
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
// and exits with status 1. The passphrase prompt turns echo off on the
// controlling terminal until the read finishes, so a signal there would
// leave echo off. Only a process in the terminal's foreground process
// group may reset it: one in the background that tries is stopped
// instead of exiting.
terminal, terminalErr := os.Open(secret.TerminalPath)
var terminalState *term.State
if terminalErr == nil {
defer func() { _ = terminal.Close() }()
terminalState, terminalErr = term.GetState(int(terminal.Fd()))
}
memguard.CatchSignal(func(os.Signal) {
if terminalErr != nil {
return
}
foreground, err := unix.IoctlGetInt(int(terminal.Fd()), unix.TIOCGPGRP)
if err == nil && foreground == unix.Getpgrp() {
_ = term.Restore(int(terminal.Fd()), terminalState)
}
}, os.Interrupt, unix.SIGTERM)
defer memguard.Purge()
err := newRootCmd().Execute()
if err != nil {
return 1
}
return 0
}
func newRootCmd() *cobra.Command {
secret.Debug("newRootCmd starting")
cmd := &cobra.Command{
Use: "secret",
Short: "A simple secrets manager",
Long: `A simple secrets manager to store and retrieve sensitive ` +
`information securely.`,
// Cobra prints the error a command returns; Entry does not.
SilenceErrors: false,
// Usage belongs only to a command called wrongly. Cobra has
// checked its arguments and flag values before this runs, but
// checks required flags (ValidateRequiredFlags) and flag groups
// (ValidateFlagGroups) only after it, so both are checked here
// to keep usage for them. An error after that comes from running
// the command, and usage would only bury it. A subcommand that
// sets its own PersistentPreRun replaces this one.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
err := cmd.ValidateRequiredFlags()
if err != nil {
return err
}
err = cmd.ValidateFlagGroups()
if err != nil {
return err
}
cmd.SilenceUsage = true
return nil
},
}
secret.Debug("Adding subcommands to root command")
// Add subcommands
cmd.AddCommand(NewInitCmd())
cmd.AddCommand(newGenerateCmd())
cmd.AddCommand(newVaultCmd())
cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd())
cmd.AddCommand(newRemoveCmd())
cmd.AddCommand(newMoveCmd())
cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd())
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newInfoCmd())
cmd.AddCommand(newCompletionCmd())
secret.Debug("newRootCmd completed")
return cmd
}
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"path/filepath"
"strings"
"testing"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
)
// testVaultName is the vault name used by the size tests.
const testVaultName = "test-vault"
// newSizeTestVault creates an in-memory vault unlocked with the test
// mnemonic and returns the filesystem and vault.
//
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
t.Helper()
fs := afero.NewMemMapFs()
// Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Set current vault
currentVaultPath := filepath.Join(testStateDir, "currentvault")
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
require.NoError(t, err)
// Get vault and set up long-term key
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
vlt.Unlock(ltIdentity)
return fs, vlt
}
// runAddSecretSizeCase adds a secret of the given size through stdin and
// verifies that it is stored.
func runAddSecretSizeCase(t *testing.T, size int) {
t.Helper()
fs, vlt := newSizeTestVault(t)
// Generate test data of specified size
testData := make([]byte, size)
_, err := rand.Read(testData)
require.NoError(t, err)
// Add newline that will be stripped
testDataWithNewline := make([]byte, 0, len(testData)+1)
testDataWithNewline = append(testDataWithNewline, testData...)
testDataWithNewline = append(testDataWithNewline, '\n')
// Create command with fake stdin
cmd := &cobra.Command{}
cmd.SetIn(bytes.NewReader(testDataWithNewline))
// Create CLI instance
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = testStateDir
cli.cmd = cmd
// Test adding the secret
secretName := fmt.Sprintf("test-secret-%d", size)
err = cli.AddSecret(secretName, false)
require.NoError(t, err)
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err)
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original (without newline)")
}
// runImportSecretSizeCase imports a secret file of the given size and
// verifies that it is stored.
func runImportSecretSizeCase(t *testing.T, size int) {
t.Helper()
fs, vlt := newSizeTestVault(t)
// Generate test data of specified size
testData := make([]byte, size)
_, err := rand.Read(testData)
require.NoError(t, err)
// Write test data to file
testFile := fmt.Sprintf("/test/secret-%d.bin", size)
err = afero.WriteFile(fs, testFile, testData, 0o600)
require.NoError(t, err)
// Create command
cmd := &cobra.Command{}
// Create CLI instance
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = testStateDir
// Test importing the secret
secretName := fmt.Sprintf("imported-secret-%d", size)
err = cli.ImportSecret(cmd, secretName, testFile, false)
require.NoError(t, err)
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err)
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
}
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
//
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
size int
}{
{
name: "1KB secret",
size: 1024,
},
{
name: "10KB secret",
size: 10 * 1024,
},
{
name: "100KB secret",
size: 100 * 1024,
},
{
name: "1MB secret",
size: 1024 * 1024,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runAddSecretSizeCase(t, tt.size)
})
}
}
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
//
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
size int
}{
{
name: "1KB file",
size: 1024,
},
{
name: "10KB file",
size: 10 * 1024,
},
{
name: "100KB file",
size: 100 * 1024,
},
{
name: "1MB file",
size: 1024 * 1024,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runImportSecretSizeCase(t, tt.size)
})
}
}
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
//
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth
sizes := []int{
1, // Single byte
100, // Small
4095, // Just under initial 4KB
4096, // Exactly 4KB
4097, // Just over 4KB
8191, // Just under 8KB (first double)
8192, // Exactly 8KB
8193, // Just over 8KB
12288, // 12KB (should trigger second double)
16384, // 16KB
32768, // 32KB (after more doublings)
65536, // 64KB
131072, // 128KB
524288, // 512KB
1048576, // 1MB
}
for _, size := range sizes {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
fs, vlt := newSizeTestVault(t)
// Create test data of exactly the specified size
// Use a pattern that's easy to verify
testData := make([]byte, size)
for i := range testData {
testData[i] = byte(i % 256)
}
// Create command with fake stdin (no newline)
cmd := &cobra.Command{}
cmd.SetIn(bytes.NewReader(testData))
// Create CLI instance
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = testStateDir
cli.cmd = cmd
// Test adding the secret
secretName := fmt.Sprintf("buffer-test-%d", size)
err = cli.AddSecret(secretName, false)
require.NoError(t, err)
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err)
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original exactly")
})
}
}
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
func TestAddSecretStreamingBehavior(t *testing.T) {
t.Parallel()
fs, vlt := newSizeTestVault(t)
// Create a custom reader that simulates slow streaming input
// This will help verify our buffer handling works correctly with partial reads
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
streamingStdin := &slowReader{
data: testData,
chunkSize: 1000, // Read 1KB at a time
}
// Create command with slow reader as stdin
cmd := &cobra.Command{}
cmd.SetIn(streamingStdin)
// Create CLI instance
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = testStateDir
cli.cmd = cmd
// Test adding the secret
err = cli.AddSecret("streaming-test", false)
require.NoError(t, err)
// Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret("streaming-test")
require.NoError(t, err)
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
}
// slowReader simulates a reader that returns data in small chunks
type slowReader struct {
data []byte
offset int
chunkSize int
}
func (r *slowReader) Read(p []byte) (int, error) {
if r.offset >= len(r.data) {
return 0, io.EOF
}
// Read at most chunkSize bytes, bounded by the remaining data and
// the destination buffer
remaining := len(r.data) - r.offset
toRead := min(r.chunkSize, remaining, len(p))
n := copy(p, r.data[r.offset:r.offset+toRead])
r.offset += n
if r.offset >= len(r.data) {
return n, io.EOF
}
return n, nil
}
+157
View File
@@ -0,0 +1,157 @@
package cli_test
import (
"bytes"
"encoding/json"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/secret"
)
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
// value to stdout, not stderr
func TestGetCommandOutputsToStdout(t *testing.T) {
t.Parallel()
// Create a temporary directory for our vault; each command is given it
// in its environment
tempDir := t.TempDir()
// Find the secret binary path
wd, err := filepath.Abs("../..")
require.NoError(t, err, "should get working directory")
secretPath := filepath.Join(wd, "secret")
testPassphrase := "test-passphrase"
// Initialize vault
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretPath, "init")
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=" + testPassphrase,
"PATH=" + "/usr/bin:/bin",
}
output, err := cmd.CombinedOutput()
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
//nolint:gosec // G204: test executes the freshly built secret binary
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + "/usr/bin:/bin",
}
cmd.Stdin = strings.NewReader("test-secret-value")
output, err = cmd.CombinedOutput()
require.NoError(t, err, "add should succeed: %s", string(output))
// Test that 'secret get' outputs to stdout, not stderr
//nolint:gosec // G204: test executes the freshly built secret binary
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + "/usr/bin:/bin",
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
require.NoError(t, err, "get should succeed")
// The secret value should be in stdout
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()),
"secret value should be in stdout")
// Nothing should be in stderr
assert.Empty(t, stderr.String(), "stderr should be empty")
}
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
// output to stdout, so that it can be piped
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
secretPath := secretBinaryPath(t)
// run runs the secret binary and returns what it wrote to stdout and to
// stderr
run := func(args ...string) (string, string) {
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretPath, args...)
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
return stdout.String(), stderr.String()
}
stdout, stderr := run("generate", "mnemonic")
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
"stdout should hold the mnemonic, not %q", stdout)
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
"the warning should be on stderr")
run("init")
stdout, _ = run("vault", "list", "--json")
var vaults map[string]any
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
"stdout should hold the vaults as JSON, not %q", stdout)
assert.Equal(t, "default", vaults["currentVault"])
stdout, _ = run("unlocker", "list", "--json")
var unlockers map[string]any
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
"stdout should hold the unlockers as JSON, not %q", stdout)
assert.Len(t, unlockers["unlockers"], 1)
stdout, _ = run("vault", "list")
assert.Contains(t, stdout, "default (current)")
stdout, _ = run("unlocker", "list")
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
}
+67
View File
@@ -0,0 +1,67 @@
package cli
import (
"bytes"
"os"
"strings"
"sneak.berlin/go/secret/internal/secret"
)
// ExecuteCommandInProcess executes a CLI command in-process for testing
func ExecuteCommandInProcess(
args []string, stdin string, env map[string]string,
) (string, error) {
secret.Debug("ExecuteCommandInProcess called", "args", args)
// Save current environment
savedEnv := make(map[string]string)
for k := range env {
savedEnv[k] = os.Getenv(k)
}
// Set test environment
for k, v := range env {
_ = os.Setenv(k, v)
}
// Create root command
rootCmd := newRootCmd()
// Capture output
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
// Set stdin if provided
if stdin != "" {
rootCmd.SetIn(strings.NewReader(stdin))
}
// Set args
rootCmd.SetArgs(args)
// Execute command
err := rootCmd.Execute()
output := buf.String()
secret.Debug("Command execution completed",
"error", err, "outputLength", len(output), "output", output)
// Add debug info for troubleshooting
if len(output) == 0 && err == nil {
secret.Debug("Warning: Command executed successfully but produced no output",
"args", args)
}
// Restore environment
for k, v := range savedEnv {
if v == "" {
_ = os.Unsetenv(k)
} else {
_ = os.Setenv(k, v)
}
}
return output, err
}
+24
View File
@@ -0,0 +1,24 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
)
//nolint:paralleltest // executes the CLI in-process against shared state
func TestOutputCapture(t *testing.T) {
// Test vault list command which we fixed
output, err := cli.ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
require.NoError(t, err)
assert.Contains(t, output, "Available vaults", "should capture vault list output")
t.Logf("vault list output: %q", output)
// Test help command
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
require.NoError(t, err)
assert.NotEmpty(t, output, "help output should not be empty")
t.Logf("help output length: %d", len(output))
}
+462
View File
@@ -0,0 +1,462 @@
// Unlock Failure Tests
//
// When a vault cannot be opened through its current unlocker, because a
// file the unlocker needs is missing, 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. A wrong passphrase gives an error that says so, without that advice.
// When the current unlocker does not exist and the vault has another that
// `secret unlocker select` accepts, the error says to select one instead.
// 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"
"syscall"
"testing"
"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"))
})
}
}
// TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
// a passphrase that does not decrypt the passphrase unlocker: it says that
// the passphrase is wrong, without the advice to use the mnemonic, since the
// unlocker still opens the vault with the right passphrase. The test then
// gives the right passphrase.
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
t.Parallel()
unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x")
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
assert.NotContains(t, err.Error(), mnemonicAdvice)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"wrong passphrase for unlocker "+filepath.Base(unlockerDir))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
// passphrase unlocker's priv.age is not an age file: the passphrase is not
// blamed, and the error ends with the advice that the mnemonic still opens
// the vault.
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
[]byte("damaged"), secret.FilePerms))
err := c.GetSecret(discardCmd(), "x")
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
require.ErrorContains(t, err, 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"))
}
// TestMissingCurrentUnlockerNamesSelect checks the error of `secret get`
// when current-unlocker names an unlocker that does not exist and the vault
// has another: it names the missing unlocker and says to select another
// one, without the advice to use the mnemonic. The test then follows that
// advice.
func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(unlockTestVaultDir, "current-unlocker"),
[]byte("missing"), secret.FilePerms))
err := c.GetSecret(discardCmd(), "x")
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get current unlocker: "+
"unlocker with ID missing not found; the vault 'default' has other "+
"unlockers: run 'secret unlocker select' with the ID of one of them "+
"to make it the current unlocker")
require.NoError(t, c.UnlockerSelect(filepath.Base(unlockerDir)))
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
// when the unlocker that current-unlocker names does not exist and the
// vault's only other unlocker is the directory "odd", whose metadata names a
// type that `secret unlocker select` refuses: the error ends with the advice
// that the mnemonic still opens the vault, not with advice to select another
// unlocker. The test then follows that advice.
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
require.NoError(t, fs.RemoveAll(unlockerDir))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
"unlocker-metadata.json"),
[]byte(`{"type": "odd"}`), secret.FilePerms))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get current unlocker: "+
"failed to read unlocker metadata: "+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"))
}
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
// binary without SB_UNLOCK_PASSPHRASE and in a new session, which has no
// 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"),
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
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: there is no terminal to ask on. 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())
}
+727
View File
@@ -0,0 +1,727 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// Unlocker type names shared across the CLI
const (
unlockerTypePassphrase = "passphrase"
unlockerTypePGP = "pgp"
cmdUseList = "list"
)
// Sentinel errors for unlocker operations
var (
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
errInvalidUnlockerType = errors.New("invalid unlocker type")
errKeyIDOnlyForPGP = errors.New(
"--keyid flag is only valid for PGP unlockers")
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
// composes "GPG key <id> is already added as an unlocker".
errGPGKeyAlreadyUnlocker = errors.New(
"is already added as an unlocker")
)
// UnlockerInfo represents unlocker information for display
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
IsCurrent bool `json:"isCurrent"`
}
// Table formatting constants
const (
unlockerIDWidth = 40
unlockerTypeWidth = 12
unlockerDateWidth = 20
unlockerFlagsWidth = 20
)
// getDefaultGPGKey returns the default GPG key ID if available
func getDefaultGPGKey() (string, error) {
ctx := context.Background()
// First try to get the configured default key using gpgconf
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
output, err := cmd.Output()
if err == nil {
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Split(line, ":")
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
// The default key is in field 10 (index 9)
return fields[9], nil
}
}
}
// If no default key is configured, get the first secret key
cmd = exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons")
output, err = cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list GPG keys: %w", err)
}
// Parse output to find the first usable secret key
for line := range strings.SplitSeq(string(output), "\n") {
// sec line indicates a secret key
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
// Field 5 contains the key ID
if len(fields) > 4 && fields[4] != "" {
return fields[4], nil
}
}
}
return "", errNoGPGSecretKeys
}
func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "unlocker",
Short: "Manage unlockers",
Long: `Create, list, and remove unlockers for the current vault.`,
}
cmd.AddCommand(newUnlockerListCmd())
cmd.AddCommand(newUnlockerAddCmd())
cmd.AddCommand(newUnlockerRemoveCmd())
cmd.AddCommand(newUnlockerSelectCmd())
return cmd
}
func newUnlockerListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: cmdUseList,
Aliases: []string{"ls"},
Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
return cli.UnlockersList(jsonOutput)
},
}
cmd.Flags().Bool("json", false, "Output in JSON format")
return cmd
}
// unlockerAddHelp returns the supported unlocker types list and their
// descriptions
func unlockerAddHelp() (string, string) {
supportedTypes := "passphrase, pgp"
typeDescriptions := "Available unlocker types:\n" +
"\n" +
" passphrase - Traditional password-based encryption\n" +
" Prompts for a passphrase that will be used to " +
"encrypt/decrypt the vault's master key.\n" +
" The passphrase is never stored in plaintext.\n" +
"\n" +
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
" Uses your existing GPG key to encrypt/decrypt " +
"the vault's master key.\n" +
" Requires gpg to be installed and configured " +
"with at least one secret key.\n" +
" Use --keyid to specify a particular key, " +
"otherwise uses your default GPG key."
return supportedTypes, typeDescriptions
}
func newUnlockerAddCmd() *cobra.Command {
supportedTypes, typeDescriptions := unlockerAddHelp()
cmd := &cobra.Command{
Use: "add <type>",
Short: "Add a new unlocker",
Long: "Add a new unlocker to the current vault.\n" +
"\n" +
typeDescriptions + "\n" +
"\n" +
"Each vault can have multiple unlockers, allowing different " +
"authentication methods\n" +
"to access the same vault. This provides flexibility and " +
"backup access options.",
Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error {
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
unlockerType := args[0]
// Validate unlocker type
validTypes := strings.Split(supportedTypes, ", ")
if !slices.Contains(validTypes, unlockerType) {
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
"Run 'secret unlocker add --help' for detailed descriptions",
errInvalidUnlockerType, unlockerType, supportedTypes)
}
// Check if --keyid was used with non-PGP type
if unlockerType != unlockerTypePGP && cmd.Flags().Changed("keyid") {
return errKeyIDOnlyForPGP
}
return cli.UnlockersAdd(unlockerType, cmd)
},
}
cmd.Flags().String("keyid", "",
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
return cmd
}
func newUnlockerRemoveCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
Short: "Remove an unlocker",
Long: `Remove an unlocker from the current vault. Asks for ` +
`confirmation first, saying whether it is the vault's last ` +
`unlocker; when stdin is not a terminal, fails unless --force ` +
`is given. Removing the current unlocker makes another of the ` +
`vault's unlockers current, if it has one that ` +
`'secret unlocker select' accepts. Warning: Without ` +
`unlockers and without your mnemonic, vault data will be ` +
`permanently inaccessible.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockersRemove(args[0], force, cmd)
},
}
cmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation, even the last unlocker")
return cmd
}
func newUnlockerSelectCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "select <unlocker-id>",
Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error {
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockerSelect(args[0])
},
}
}
// UnlockersList lists unlockers in the current vault, each under its ID,
// the name of its directory in unlockers.d
func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
// Get the current unlocker ID
var currentUnlockerID string
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err == nil {
currentUnlockerID = currentUnlocker.GetID()
}
unlockerMetadata, err := vlt.ListUnlockers()
if err != nil {
return err
}
var unlockers []UnlockerInfo
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
metadata := unlockerMetadata[unlockerID]
unlockers = append(unlockers, UnlockerInfo{
ID: unlockerID,
Type: metadata.Type,
CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags,
IsCurrent: unlockerID == currentUnlockerID,
})
}
if jsonOutput {
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
}
return cli.printUnlockersTable(unlockers)
}
// printUnlockersJSON prints unlockers in JSON format
func (cli *Instance) printUnlockersJSON(
unlockers []UnlockerInfo, currentUnlockerID string,
) error {
output := map[string]any{
"unlockers": unlockers,
"currentUnlockerID": currentUnlockerID,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
return nil
}
// printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
out := cli.cmd.OutOrStdout()
if len(unlockers) == 0 {
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
return nil
}
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
prefix := " "
if unlocker.IsCurrent {
prefix = "* "
}
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
prefix,
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags)
}
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
return nil
}
// UnlockersAdd adds a new unlocker
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
switch unlockerType {
case unlockerTypePassphrase:
return cli.addPassphraseUnlocker(cmd)
case unlockerTypePGP:
return cli.addPGPUnlocker(cmd)
default:
supportedTypes := "passphrase, pgp"
return fmt.Errorf("%w: %s (supported: %s)",
errInvalidUnlockerType, unlockerType, supportedTypes)
}
}
// autoSelectUnlocker selects the newly created unlocker as current,
// printing a warning if selection fails
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
err := vlt.SelectUnlocker(unlockerID)
if err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
}
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the
// long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// The new unlocker gets the passphrase from the environment, which also
// unlocks the current passphrase unlocker, else the one entered here
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
}
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
return err
}
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// CreatePassphraseUnlocker has already made it the current unlocker
cmd.Printf("Automatically selected as current unlocker\n")
return nil
}
// addPGPUnlocker creates a PGP unlocker in the current vault
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
// Get GPG key ID from flag, environment, or default key
var gpgKeyID string
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
gpgKeyID = flagKeyID
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
gpgKeyID = envKeyID
} else {
// Try to get the default GPG key
defaultKeyID, err := getDefaultGPGKey()
if err != nil {
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
}
gpgKeyID = defaultKeyID
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
}
// Check if this key is already added as an unlocker
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
// and the new unlocker's metadata both use this result
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
// Check if this GPG key is already added
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
if err != nil {
return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w",
gpgKeyID, err,
)
}
if exists {
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
}
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return err
}
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
// Auto-select the newly created unlocker
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
return nil
}
// UnlockersRemove removes an unlocker from the current vault, after asking
// the user to confirm unless force is set.
func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command,
) error {
var found unlockerToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findUnlockerToRemove(unlockerID)
return found.question, err
})
if err != nil {
return err
}
defer release()
return cli.removeUnlocker(unlockerID, found, cmd)
}
// unlockerToRemove is what removing an unlocker removes, as
// findUnlockerToRemove found it.
type unlockerToRemove struct {
vlt *vault.Vault
// last is set when the unlocker counts as the vault's last one, and
// secrets is then the number of secrets in the vault.
last bool
secrets int
// question names what is removed, for the user to confirm.
question string
}
// findUnlockerToRemove checks that the current vault has the unlocker and
// finds whether it is the vault's last one.
func (cli *Instance) findUnlockerToRemove(
unlockerID string,
) (unlockerToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return unlockerToRemove{}, err
}
exists, err := vlt.HasUnlocker(unlockerID)
if err != nil {
return unlockerToRemove{}, err
}
if !exists {
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
unlockerID, vault.ErrUnlockerNotFound)
}
// Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed.
unlockers, err := vlt.ListUnlockers()
if err != nil {
return unlockerToRemove{},
fmt.Errorf("failed to list unlockers: %w", err)
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return unlockerToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
found := unlockerToRemove{
vlt: vlt,
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
"'%s'? It is not the vault's last unlocker.",
unlockerID, vlt.GetName()),
}
if len(unlockers) == 1 {
_, found.last = unlockers[unlockerID]
}
// unlockerID may instead name a directory left out of the list. If its
// metadata file is missing or corrupt it is not a working unlocker, so
// removing it never removes the last one. If the file cannot be checked
// for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
found.last = true
}
if found.last {
found.secrets, err = vlt.NumSecrets()
if err != nil {
return unlockerToRemove{},
fmt.Errorf("failed to count secrets: %w", err)
}
found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+
"the last unlocker of vault '%s', which holds %d secret(s)? "+
"Without an unlocker the vault opens only with its mnemonic.",
unlockerID, vlt.GetName(), found.secrets)
}
return found, nil
}
// removeUnlocker removes the unlocker that findUnlockerToRemove found. The
// caller holds the state directory lock.
func (cli *Instance) removeUnlocker(
unlockerID string, found unlockerToRemove, cmd *cobra.Command,
) error {
if found.last && found.secrets > 0 {
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
"have your mnemonic phrase to access this vault again!")
}
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
if err != nil {
return err
}
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
if newCurrentID != "" {
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
}
return nil
}
// metadataUnreadable reports whether checking for or reading the metadata
// file in the unlocker directory unlockerDir fails. A missing file is not
// a failure.
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
exists, err := afero.Exists(fs, metadataPath)
if err == nil && exists {
_, err = afero.ReadFile(fs, metadataPath)
}
return err != nil
}
// UnlockerSelect selects an unlocker as current
func (cli *Instance) UnlockerSelect(unlockerID string) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
return vlt.SelectUnlocker(unlockerID)
}
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
// for the GPG key with the given fingerprint. It returns an error, and no
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
// the caller must then not create the unlocker. It reads unlockers.d itself
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over.
func (cli *Instance) pgpUnlockerExists(
vlt *vault.Vault, fingerprint string,
) (bool, error) {
vaultDir, err := vlt.GetDirectory()
if err != nil {
return false, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
entries, err := afero.ReadDir(cli.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, entry.Name())
metadataBytes, err := afero.ReadFile(
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return false, fmt.Errorf(
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
)
}
var metadata secret.PGPUnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
continue
}
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
return true, nil
}
}
return false, nil
}
+144
View File
@@ -0,0 +1,144 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"strings"
"testing"
"github.com/awnumar/memguard"
"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"
)
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
const unknownTestGPGUserID = "not-in-keyring@example.com"
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
const (
addTestSecretName = "api-key"
addTestSecretValue = "value"
)
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
// with a passphrase unlocker, getting the vault's long-term key from the
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
// then reads a secret with neither the mnemonic nor the passphrase given, so
// through the new unlocker, which the add selects.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlocker(t *testing.T) {
newTestGPGKey(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
tests := []struct {
name string
// mnemonic is the mnemonic given while the unlocker is added, or nil.
mnemonic *memguard.LockedBuffer
}{
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
{"long-term key from the current unlocker", nil},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret(addTestSecretName,
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
require.NoError(t, err)
_, err = vlt.CreatePassphraseUnlocker(
memguard.NewBufferFromBytes([]byte(testPassphrase)))
require.NoError(t, err)
instance, cmd := newTestInstance(fs)
instance.Mnemonic = test.mnemonic
instance.UnlockPassphrase = passphrase
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
current, err := reopened.GetCurrentUnlocker()
require.NoError(t, err)
assert.Equal(t, unlockerTypePGP, current.GetType())
value, err := reopened.GetSecret(addTestSecretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, addTestSecretValue, value.String())
})
}
}
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
// the keyring does not hold fails at looking up the key's fingerprint and
// leaves no new unlocker directory. The error must come from the lookup: a
// lookup moved after anything is written would also come after getting the
// vault's long-term key, which fails first here: this vault's unlockers hold
// no keys.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
newTestGPGKey(t)
base := newListTestVault(t, 1)
instance, cmd := newTestInstance(base)
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
err := instance.addPGPUnlocker(cmd)
require.ErrorIs(t, err, secret.ErrGPGKeyNotFound)
assertDirEntries(t, base,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
listTestUnlockerDirOne)
}
// TestUnlockerAddKnowsOnlyPassphraseAndPGP is a regression test for
// https://git.eeqj.de/sneak/secret/issues/132: `secret unlocker add` takes
// only passphrase and pgp, on every platform. keychain and secure-enclave
// fail as unknown types, and neither --help nor shell completion offers
// them.
//
//nolint:paralleltest // executes the CLI in-process and sets the environment
func TestUnlockerAddKnowsOnlyPassphraseAndPGP(t *testing.T) {
env := map[string]string{secret.EnvStateDir: t.TempDir()}
removedTypes := []string{"keychain", "secure-enclave"}
for _, unlockerType := range removedTypes {
_, err := ExecuteCommandInProcess(
[]string{"unlocker", "add", unlockerType}, "", env)
require.ErrorIs(t, err, errInvalidUnlockerType)
instance, cmd := newTestInstance(newListTestVault(t, 1))
require.ErrorIs(t, instance.UnlockersAdd(unlockerType, cmd),
errInvalidUnlockerType)
}
// --help prints the command's Long text, and shell completion offers
// its ValidArgs.
addCmd := newUnlockerAddCmd()
help := addCmd.Long
completions := strings.Join(addCmd.ValidArgs, " ")
for _, offered := range []string{help, completions} {
assert.Contains(t, offered, unlockerTypePassphrase)
assert.Contains(t, offered, unlockerTypePGP)
for _, unlockerType := range removedTypes {
assert.NotContains(t, offered, unlockerType)
}
}
}
+191
View File
@@ -0,0 +1,191 @@
// Corrupt Unlocker Tests
//
// `secret unlocker select` and `secret unlocker remove` find an unlocker
// by its ID. These tests give the first unlocker, which sorts before the
// one the commands act on, metadata that is not JSON, and check that the
// commands step past it, and that it can itself be removed by its
// directory name, which `secret unlocker list` names in its warning, as can
// one with no metadata file. A last test checks that an unlocker whose
// metadata file cannot be read counts as the last unlocker when it is
// removed by its directory name.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"strings"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
)
// newCorruptUnlockerVault returns the two-unlocker test vault with the
// metadata of the first unlocker replaced by text that is not JSON.
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
t.Helper()
fs := newListTestVault(t, 2)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
listTestUnlockerDirOne, listTestMetadataFileName),
[]byte("not json"), listTestFilePerm))
return fs
}
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
// can be selected, and that the corrupt one, having no type to be used as,
// cannot be selected by its directory name.
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, listTestUnlockerDirTwo, string(current))
err = instance.UnlockerSelect(listTestUnlockerDirOne)
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
}
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
// counts as the vault's last one, since the corrupt unlocker cannot unlock
// the vault, and that the corrupt one, removed by its directory name, does
// not. Either is removed once the user confirms.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel()
tests := []struct {
name string
unlockerID string
wantLast bool
wantEntries []string
}{
{
name: "the other unlocker",
unlockerID: listTestUnlockerDirTwo,
wantLast: true,
wantEntries: []string{listTestUnlockerDirOne},
},
{
name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne,
wantEntries: []string{listTestUnlockerDirTwo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
instance, cmd := newTestInstance(fs)
found, err := instance.findUnlockerToRemove(tt.unlockerID)
require.NoError(t, err)
assert.Equal(t, tt.wantLast, found.last)
instance.terminal = strings.NewReader("y\n")
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName),
listTestUnlockersDirName),
tt.wantEntries...)
})
}
}
// TestUnlockerRemoveWithoutMetadata asserts that a partial unlocker
// directory, one with no metadata file, removed by its directory name from
// a vault with secrets, does not count as the vault's last unlocker, since
// it cannot unlock the vault, so the question says it is not. It is
// removed once the user confirms.
func TestUnlockerRemoveWithoutMetadata(t *testing.T) {
t.Parallel()
fs := newListTestVault(t, 2)
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
require.NoError(t, fs.Remove(filepath.Join(
unlockersDir, listTestUnlockerDirOne, listTestMetadataFileName)))
writeTestSecret(t, fs, vaultDir)
instance, cmd := newTestInstance(fs)
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.NoError(t, err)
assert.False(t, found.last)
assert.Contains(t, found.question, "not the vault's last unlocker")
instance.terminal = strings.NewReader("y\n")
require.NoError(t, instance.UnlockersRemove(listTestUnlockerDirOne, false, cmd))
assertDirEntries(t, fs, unlockersDir, listTestUnlockerDirTwo)
}
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
// of a vault with secrets, removed by its directory name when its metadata
// file cannot be checked for or read, counts as the vault's last unlocker,
// so the question warns that it is: listing leaves it out, but it may
// still be the vault's only working unlocker. It is then removed. The
// state directory lock refuses the failing filesystem, so the test calls
// findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
// make its checks and, once it holds the lock, to remove the unlocker.
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
listTestMetadataFileName)
tests := []struct {
name string
wrap func(base afero.Fs) afero.Fs
}{
{
name: "checking for the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
},
},
{
name: "reading the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.wrap(base))
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.NoError(t, err)
assert.True(t, found.last)
assert.Contains(t, found.question, "the last unlocker")
require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
assertDirEntries(t, base, unlockersDir)
})
}
}
+78
View File
@@ -0,0 +1,78 @@
//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.
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)
}
+323
View File
@@ -0,0 +1,323 @@
// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when an unlocker's metadata
// cannot be read or used:
//
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed.
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
// file cannot be checked for or read is left out, and the other is
// still listed.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
const (
// listTestStateDir is the state directory of the synthetic vault used
// by the unlocker listing tests.
listTestStateDir = "/state"
// listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
// in the PGP unlockers' metadata.
listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d, and so the unlockers' IDs.
listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory holding the unlockers.
listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name.
listTestMetadataFileName = "unlocker-metadata.json"
// listTestDirPerm and listTestFilePerm are the fixture permissions.
listTestDirPerm = 0o700
listTestFilePerm = 0o600
)
// errUnlockersDirUnreadable is returned by the test filesystem in place of
// a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs fails every open of unlockers.d, as when the
// directory cannot be read.
type unlockersDirFailFs struct {
afero.Fs
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName {
return nil, errUnlockersDirUnreadable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name)
}
// errMetadataUnreadable is returned by the test filesystem in place of a
// successful open of one unlocker's metadata file.
var errMetadataUnreadable = errors.New("input/output error")
// metadataReadFailFs fails every open of the file at unreadablePath. The
// file still exists, so checking for it succeeds and only reading it fails.
type metadataReadFailFs struct {
afero.Fs
unreadablePath string
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
if name == f.unreadablePath {
return nil, errMetadataUnreadable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name)
}
// errMetadataUncheckable is returned by the test filesystem in place of a
// successful check for one unlocker's metadata file.
var errMetadataUncheckable = errors.New("permission denied")
// metadataStatFailFs fails every check for whether the file at
// uncheckablePath exists, as when its unlocker directory cannot be entered.
type metadataStatFailFs struct {
afero.Fs
uncheckablePath string
}
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
if name == f.uncheckablePath {
return nil, errMetadataUncheckable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Stat(name)
}
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
// metadata recording the GPG key ID keyID.
func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string,
) {
t.Helper()
metadata := secret.PGPUnlockerMetadata{
UnlockerMetadata: secret.UnlockerMetadata{
Type: unlockerTypePGP,
CreatedAt: createdAt,
},
GPGKeyID: keyID,
}
encoded, err := json.Marshal(metadata)
require.NoError(t, err)
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
listTestFilePerm,
))
}
// newListTestVault builds a synthetic vault on a MemMapFs containing the
// given number of PGP unlockers, with the first one selected as current.
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
t.Helper()
base := &afero.MemMapFs{}
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
require.NoError(t, afero.WriteFile(
base, filepath.Join(listTestStateDir, "currentvault"),
[]byte(listTestVaultName), listTestFilePerm,
))
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
names = names[:unlockerCount]
for i, name := range names {
writePGPUnlocker(t, base, unlockersDir, name,
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)),
)
}
require.NoError(t, afero.WriteFile(
base, filepath.Join(vaultDir, "current-unlocker"),
[]byte(names[0]), listTestFilePerm,
))
return base
}
// listUnlockersJSON runs UnlockersList in JSON mode against the given
// filesystem and decodes the emitted unlocker rows.
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
t.Helper()
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
cmd.SetErr(&buf)
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
require.NoError(t, instance.UnlockersList(true))
var decoded struct {
Unlockers []UnlockerInfo `json:"unlockers"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
return decoded.Unlockers
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2)
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent)
}
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
// 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) {
t.Parallel()
healthyID := listTestUnlockerDirOne
tests := []struct {
name string
metadata string
wantIDs []string
}{
{
name: "not JSON",
metadata: "not json",
wantIDs: []string{healthyID},
},
{
name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "GPG key ID missing",
metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newListTestVault(t, 2)
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
listTestVaultName, listTestUnlockersDirName,
listTestUnlockerDirTwo, listTestMetadataFileName)
require.NoError(t, afero.WriteFile(
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
))
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, len(tt.wantIDs))
for i, wantID := range tt.wantIDs {
assert.Equal(t, wantID, unlockers[i].ID)
}
})
}
}
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
// metadata file cannot be checked for or cannot be read is left out of the
// listing, and the other unlocker is still listed with its real ID. The
// failing one sorts first, so finding the other's ID has to step past it
// as well.
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
t.Parallel()
failingPath := filepath.Join(listTestStateDir, "vaults.d",
listTestVaultName, listTestUnlockersDirName,
listTestUnlockerDirOne, listTestMetadataFileName)
tests := []struct {
name string
wrap func(base afero.Fs) afero.Fs
}{
{
name: "checking for the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
},
},
{
name: "reading the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := tt.wrap(newListTestVault(t, 2))
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the unlocker with usable metadata may be listed")
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
"the listed row must carry the real unlocker ID")
})
}
}
+94
View File
@@ -0,0 +1,94 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"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"
)
// TestUnlockerRemoveCurrent is a regression test for
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
// makes the passphrase unlocker current and says so, and the vault then
// opens through it. Removing the passphrase unlocker too removes
// current-unlocker.
//
// The second case also gives the vault the unlocker directory "odd", whose
// metadata names a type that `secret unlocker select` refuses, and which
// sorts before the passphrase unlocker: neither removal makes it current.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestUnlockerRemoveCurrent(t *testing.T) {
tests := []struct {
name string
withOdd bool
}{
{name: "two unlockers", withOdd: false},
{name: "and the directory odd", withOdd: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newTestGPGKey(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir,
listTestVaultName, testMnemonicBuffer(t), nil)
require.NoError(t, err)
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
if tt.withOdd {
require.NoError(t, afero.WriteFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
"odd", "unlocker-metadata.json"),
[]byte(`{"type": "odd"}`), secret.FilePerms))
}
instance, cmd := newTestInstance(fs)
instance.UnlockPassphrase = passphrase
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
pgpUnlocker, err := vlt.GetCurrentUnlocker()
require.NoError(t, err)
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
var output bytes.Buffer
cmd.SetOut(&output)
require.NoError(t,
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
assert.Contains(t, output.String(),
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
reopened.UnlockPassphrase = passphrase
_, err = reopened.GetOrDeriveLongTermKey()
require.NoError(t, err)
require.NoError(t,
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
exists, err := afero.Exists(fs, filepath.Join(
testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.False(t, exists,
"current-unlocker is left after the passphrase unlocker")
})
}
}
+389
View File
@@ -0,0 +1,389 @@
// Unreadable Directory Tests
//
// The checks that guard adding a PGP unlocker (is this key already an
// unlocker?), removing the last unlocker and removing a vault (does the
// vault hold secrets?), removing a secret (how many versions does it
// have?), and importing a mnemonic (does the vault already have a
// long-term key?) each look at the vault on disk before acting.
// When that look fails they must refuse to act, not read the failure as
// "nothing there" and go ahead.
//
// The tests make the look fail with a wrapper around the in-memory
// filesystem, which the state directory lock refuses. So they call the
// function each command runs once it holds the lock, such as addPGPUnlocker
// for UnlockersAdd, or, for a removal, the function that makes its checks,
// such as findVaultToRemove for RemoveVault, which runs again under the
// lock before anything is removed, with --force or without.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
const (
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
// PGP unlocker tests generate, and the --keyid they pass.
unreadableTestGPGUserID = "unlocker-test@example.com"
// unreadableTestSecretName is the secret stored in the vaults the
// removal tests remove from.
unreadableTestSecretName = "api-key"
// unreadableTestOtherVault is a second vault for the vault removal
// test, since the last vault can never be removed.
unreadableTestOtherVault = "work"
// unreadableTestSecretsDirName is the directory holding a vault's
// secrets, and unreadableTestCurrentFileName the per-secret file
// naming its current version.
unreadableTestSecretsDirName = "secrets.d"
unreadableTestCurrentFileName = "current"
)
// errStatFailed is returned by statFailFs in place of a successful stat.
var errStatFailed = errors.New("input/output error")
// statFailFs fails every Stat of one path, as an I/O or permission error
// on that path would.
type statFailFs struct {
afero.Fs
path string
}
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
if name == f.path {
return nil, errStatFailed
}
return f.Fs.Stat(name)
}
// errOpenFailed is returned by openFailFs in place of a successful open.
var errOpenFailed = errors.New("permission denied")
// openFailFs fails every Open of one path, as a directory without read
// permission does: checking that it exists succeeds, listing it fails.
type openFailFs struct {
afero.Fs
path string
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *openFailFs) Open(name string) (afero.File, error) {
if name == f.path {
return nil, errOpenFailed
}
return f.Fs.Open(name)
}
// testVaultDir returns the directory of the named vault in the synthetic
// state directory built by newListTestVault.
func testVaultDir(vaultName string) string {
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
}
// newTestInstance returns a CLI instance on fs whose output is discarded.
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
}
// assertDirEntries asserts that dir holds exactly the named entries.
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
t.Helper()
entries, err := afero.ReadDir(fs, dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, want, names)
}
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
// without a passphrase there, with a subkey for encryption, and returns the
// key's fingerprint.
func newTestGPGKey(t *testing.T) string {
t.Helper()
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
// socket, which is created inside GNUPGHOME there.
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
t.Setenv("GNUPGHOME", gnupgHome)
t.Cleanup(func() {
// Stop the gpg-agent that key generation starts; cleanups run in
// reverse order, so this happens before its directory is removed.
// t.Context is already canceled when cleanup runs.
ctx := context.WithoutCancel(t.Context())
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
})
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
"--pinentry-mode", "loopback", "--passphrase", "",
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
).CombinedOutput()
require.NoError(t, err, "generating the test GPG key: %s", output)
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
require.NoError(t, err)
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
"--pinentry-mode", "loopback", "--passphrase", "",
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
).CombinedOutput()
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
output)
return fingerprint
}
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
// against fs.
func addTestPGPUnlocker(fs afero.Fs) error {
instance, cmd := newTestInstance(fs)
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
return instance.addPGPUnlocker(cmd)
}
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
// a key that already has one fails, and creates no unlocker directory,
// when unlockers.d or the existing unlocker's metadata file cannot be
// read; and, as the control case, that the existing unlocker is refused
// as a duplicate when everything can be read.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
fingerprint := newTestGPGKey(t)
unlockersDir := filepath.Join(
testVaultDir(listTestVaultName), listTestUnlockersDirName)
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
// newVaultWithDuplicate returns a vault holding an unlocker for the
// test key, beside the one newListTestVault writes.
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
t.Helper()
base := newListTestVault(t, 1)
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
fingerprint)
return base
}
tests := []struct {
name string
failFs func(base afero.Fs) afero.Fs
wantErr error
// wantPath is the path the error must name.
wantPath string
}{
{
name: "unlockers.d unreadable",
failFs: func(base afero.Fs) afero.Fs {
return &unlockersDirFailFs{Fs: base}
},
wantErr: errUnlockersDirUnreadable,
wantPath: unlockersDir,
},
{
name: "existing unlocker's metadata unreadable",
failFs: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{
Fs: base,
unreadablePath: filepath.Join(
duplicateDir, listTestMetadataFileName),
}
},
wantErr: errMetadataUnreadable,
wantPath: duplicateDir,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := newVaultWithDuplicate(t)
err := addTestPGPUnlocker(tt.failFs(base))
require.ErrorIs(t, err, tt.wantErr)
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assert.Contains(t, err.Error(), tt.wantPath,
"the error must name what it could not read")
assertDirEntries(t, base, unlockersDir,
listTestUnlockerDirOne, listTestUnlockerDirTwo)
})
}
t.Run("duplicate refused", func(t *testing.T) {
base := newVaultWithDuplicate(t)
err := addTestPGPUnlocker(base)
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assertDirEntries(t, base, unlockersDir,
listTestUnlockerDirOne, listTestUnlockerDirTwo)
})
}
// writeTestSecret stores a secret with a current-version pointer, which is
// what makes it count as a secret, in the given vault directory.
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
t.Helper()
secretDir := filepath.Join(
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(secretDir, unreadableTestCurrentFileName),
[]byte("20260809.001"), listTestFilePerm))
}
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
// unlocker is kept when the secrets it protects cannot be counted.
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
for _, path := range []string{
secretsDir,
filepath.Join(secretsDir, unreadableTestSecretName,
unreadableTestCurrentFileName),
} {
t.Run(filepath.Base(path), func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
})
}
}
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
// kept when whether it holds secrets cannot be determined: when checking
// that secrets.d exists fails, and when it exists but cannot be listed.
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(unreadableTestOtherVault)
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
tests := []struct {
name string
failFs func(base afero.Fs) afero.Fs
wantErr error
}{
{
name: "check fails",
failFs: func(base afero.Fs) afero.Fs {
return &statFailFs{Fs: base, path: secretsDir}
},
wantErr: errStatFailed,
},
{
name: "listing fails",
failFs: func(base afero.Fs) afero.Fs {
return &openFailFs{Fs: base, path: secretsDir}
},
wantErr: errOpenFailed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(tt.failFs(base))
_, err := instance.findVaultToRemove(unreadableTestOtherVault)
require.ErrorIs(t, err, tt.wantErr)
exists, err := afero.DirExists(base, vaultDir)
require.NoError(t, err)
assert.True(t, exists, "the vault must not be removed")
})
}
}
// TestRemoveSecretAbortsWhenVersionsUnreadable asserts that a secret is
// kept when its versions directory exists but cannot be listed, so that
// the question cannot say how many versions would be removed.
func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
t.Parallel()
secretDir := filepath.Join(testVaultDir(listTestVaultName),
unreadableTestSecretsDirName, unreadableTestSecretName)
versionsDir := filepath.Join(secretDir, "versions")
base := newListTestVault(t, 1)
writeTestSecret(t, base, testVaultDir(listTestVaultName))
require.NoError(t, base.MkdirAll(versionsDir, listTestDirPerm))
instance, _ := newTestInstance(&openFailFs{Fs: base, path: versionsDir})
_, err := instance.findSecretToRemove(unreadableTestSecretName)
require.ErrorIs(t, err, errOpenFailed)
exists, err := afero.DirExists(base, secretDir)
require.NoError(t, err)
assert.True(t, exists, "the secret must not be removed")
}
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
// stops when whether the vault already has a long-term key cannot be
// determined.
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
instance, cmd := newTestInstance(&statFailFs{
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
})
err := instance.importMnemonic(cmd, listTestVaultName)
require.ErrorIs(t, err, errStatFailed)
}
+46
View File
@@ -0,0 +1,46 @@
package cli_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"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.
const usageHeading = "Usage:"
// A command called wrongly gets usage after its error; a command that
// fails while running gets its error alone. Either way the command fails
// and its error is shown exactly once.
//
//nolint:paralleltest // executes the CLI in-process and sets the environment
func TestUsageOnlyForCallErrors(t *testing.T) {
// No vault in the state directory, so `get x` fails while running.
env := map[string]string{secret.EnvStateDir: t.TempDir()}
tests := []struct {
call string
wantUsage bool
}{
{call: "get", wantUsage: true},
{call: "get x y", wantUsage: true},
{call: "get --no-such-flag x", wantUsage: true},
{call: "generate secret x --length abc", wantUsage: true},
{call: "import x", wantUsage: true},
{call: "get x", wantUsage: false},
}
for _, tt := range tests {
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
require.Error(t, err, "%q should fail", tt.call)
assert.Equal(t, 1, strings.Count(output, err.Error()),
"%q should show its error once:\n%s", tt.call, output)
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
"usage shown for %q:\n%s", tt.call, output)
}
}
+681
View File
@@ -0,0 +1,681 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"log"
"path/filepath"
"slices"
"strings"
"time"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
)
// Sentinel errors for vault operations
var (
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
errVaultHasLongTermKey = errors.New(
"already has a long-term key configured")
errMnemonicEnvNotSet = errors.New(
"SB_SECRET_MNEMONIC environment variable not set")
errPassphraseEnvNotSet = errors.New(
"SB_UNLOCK_PASSPHRASE environment variable not set")
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
)
func newVaultCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "vault",
Short: "Manage vaults",
Long: `Create, list, and select vaults for organizing secrets.`,
}
cmd.AddCommand(newVaultListCmd())
cmd.AddCommand(newVaultCreateCmd())
cmd.AddCommand(newVaultSelectCmd())
cmd.AddCommand(newVaultImportCmd())
cmd.AddCommand(newVaultRemoveCmd())
return cmd
}
func newVaultListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: cmdUseList,
Aliases: []string{"ls"},
Short: "List available vaults",
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ListVaults(cmd, jsonOutput)
},
}
cmd.Flags().Bool("json", false, "Output in JSON format")
return cmd
}
func newVaultCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create <name>",
Short: "Create a new vault",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.CreateVault(cmd, args[0])
},
}
}
func newVaultSelectCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "select <name>",
Short: "Select a vault as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.SelectVault(cmd, args[0])
},
}
}
func newVaultImportCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "import <vault-name>",
Short: "Import a mnemonic into a vault",
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
`(default if not specified).`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
vaultName := "default"
if len(args) > 0 {
vaultName = args[0]
}
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.VaultImport(cmd, vaultName)
},
}
}
func newVaultRemoveCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
Short: "Remove a vault",
Long: `Remove a vault and all its secrets. Asks for ` +
`confirmation first, naming how many secrets the vault ` +
`holds; when stdin is not a terminal, fails unless --force ` +
`is given. Will automatically switch to another vault if ` +
`removing the currently selected one. The last vault ` +
`cannot be removed.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveVault(cmd, args[0], force)
},
}
cmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation, even a vault that contains secrets")
return cmd
}
// ListVaults lists all available vaults
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return err
}
out := cmd.OutOrStdout()
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
// Get current vault name for context
currentVault := ""
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err == nil {
currentVault = currentVlt.GetName()
}
result := map[string]any{
"vaults": vaults,
"currentVault": currentVault,
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
_, _ = fmt.Fprintln(out, string(jsonBytes))
} else {
// Text output
_, _ = fmt.Fprintln(out, "Available vaults:")
if len(vaults) == 0 {
_, _ = fmt.Fprintln(out, " (none)")
} else {
// Try to get current vault for marking
currentVault := ""
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err == nil {
currentVault = currentVlt.GetName()
}
for _, vaultName := range vaults {
if vaultName == currentVault {
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
} else {
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
}
}
}
}
return nil
}
// resolvePassphrase returns the unlock passphrase from the environment,
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
// returned cleanup function must be deferred by the caller.
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
if cli.UnlockPassphrase != nil {
secret.Debug("Using unlock passphrase from environment variable")
return cli.UnlockPassphrase, func() {}, nil
}
secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return nil, nil, err
}
return passphraseBuffer, passphraseBuffer.Destroy, nil
}
// CreateVault creates a new vault
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
return errMnemonicEmpty
}
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonicStr)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonicStr) {
return errInvalidMnemonicPhrase
}
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer cleanupPassphrase()
// Create the vault with its passphrase unlocker
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
mnemonic, passphraseBuffer)
if err != nil {
return err
}
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return err
}
cmd.Printf("Created vault '%s'\n", vlt.GetName())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
return nil
}
// SelectVault selects a vault as the current one
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
err = vault.SelectVault(cli.fs, cli.stateDir, name)
if err != nil {
return err
}
cmd.Printf("Selected vault '%s' as current\n", name)
return nil
}
// vaultImportPreflight verifies the vault exists without a long-term key
// and returns the vault directory, public key path, and validated mnemonic
func (cli *Instance) vaultImportPreflight(
vlt *vault.Vault, vaultName string,
) (string, string, string, error) {
// Check if vault exists
vaultDir, err := vlt.GetDirectory()
if err != nil {
return "", "", "", err
}
exists, err := afero.DirExists(cli.fs, vaultDir)
if err != nil {
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
}
if !exists {
return "", "", "", fmt.Errorf("vault '%s' %w",
vaultName, vault.ErrVaultNotFound)
}
// Check if vault already has a public key
pubKeyPath := vaultDir + "/pub.age"
exists, err = afero.Exists(cli.fs, pubKeyPath)
if err != nil {
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
}
if exists {
return "", "", "", fmt.Errorf("vault '%s' %w",
vaultName, errVaultHasLongTermKey)
}
// Get mnemonic from environment
if cli.Mnemonic == nil {
return "", "", "", errMnemonicEnvNotSet
}
mnemonic := cli.Mnemonic.String()
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonic)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonic) {
return "", "", "", errInvalidMnemonicPhrase
}
return vaultDir, pubKeyPath, mnemonic, nil
}
// updateVaultImportMetadata stores the derivation info in vault metadata
func updateVaultImportMetadata(
fs afero.Fs, vaultDir string, derivationIndex uint32,
publicKeyHash, familyHash string,
) error {
// Load existing metadata
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
// If metadata doesn't exist, create new
existingMetadata = &vault.Metadata{
CreatedAt: time.Now(),
}
}
// Update metadata with new derivation info
existingMetadata.DerivationIndex = derivationIndex
existingMetadata.PublicKeyHash = publicKeyHash
existingMetadata.MnemonicFamilyHash = familyHash
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
if err != nil {
secret.Debug("Failed to save vault metadata", "error", err)
return fmt.Errorf("failed to save vault metadata: %w", err)
}
secret.Debug("Saved vault metadata with derivation index and public key hash")
return nil
}
// VaultImport imports a mnemonic into a specific vault, holding the state
// directory lock while importMnemonic runs
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
err := vault.ValidateVaultName(vaultName)
if err != nil {
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.importMnemonic(cmd, vaultName)
}
// importMnemonic gives the vault a long-term key derived from the mnemonic
// and a passphrase unlocker
func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error {
secret.Debug("Importing mnemonic into vault",
"vault_name", vaultName, "state_dir", cli.stateDir)
// Get the specific vault by name
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
if err != nil {
return err
}
// Get the next available derivation index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
if err != nil {
secret.Debug("Failed to get next derivation index", "error", err)
return fmt.Errorf("failed to get next derivation index: %w", err)
}
secret.Debug("Using derivation index", "index", derivationIndex)
// Derive long-term key from mnemonic with the appropriate index
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil {
return fmt.Errorf("failed to derive long-term key: %w", err)
}
// Store long-term public key in vault
ltPublicKey := ltIdentity.Recipient().String()
secret.Debug("Storing long-term public key",
"pubkey", ltPublicKey, "vault_dir", vaultDir)
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
if err != nil {
return fmt.Errorf("failed to store long-term public key: %w", err)
}
// Calculate public key hash from the actual derivation index being used
// This is used to verify that the derived key matches what was stored
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
// Calculate family hash from index 0 (same for all vaults with this
// mnemonic). This is used to identify which vaults belong to the same
// mnemonic family.
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return fmt.Errorf("failed to derive identity for index 0: %w", err)
}
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
err = updateVaultImportMetadata(
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
if err != nil {
return err
}
// Get passphrase from environment variable
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
return errPassphraseEnvNotSet
}
secret.Debug("Using unlock passphrase from environment variable")
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlocker: %w", err)
}
cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
cmd.Printf("Long-term public key: %s\n", ltPublicKey)
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil
}
// countVaultSecrets returns the number of secrets in the vault directory
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(cli.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err)
}
if !exists {
return 0, nil
}
entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err)
}
return len(entries), nil
}
// switchAwayFromVault selects another vault as current before removal
func (cli *Instance) switchAwayFromVault(
cmd *cobra.Command, vaults []string, name string,
) error {
// Find another vault to switch to
var newVault string
for _, v := range vaults {
if v != name {
newVault = v
break
}
}
// Switch to the new vault
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
if err != nil {
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
}
cmd.Printf("Switched current vault to '%s'\n", newVault)
return nil
}
// RemoveVault removes a vault and all its secrets, after asking the user
// to confirm unless force is set.
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
err := vault.ValidateVaultName(name)
if err != nil {
return err
}
var found vaultToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVaultToRemove(name)
return found.question, err
})
if err != nil {
return err
}
defer release()
// If removing current vault, switch to another vault first
if found.isCurrent {
err = cli.switchAwayFromVault(cmd, found.vaults, name)
if err != nil {
return err
}
}
// Remove the vault directory
err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil {
return fmt.Errorf("failed to remove vault directory: %w", err)
}
cmd.Printf("Removed vault '%s'\n", name)
if found.secrets > 0 {
cmd.Printf("Warning: Vault contained secrets that have been " +
"permanently deleted\n")
}
return nil
}
// vaultToRemove is what removing a vault removes, as findVaultToRemove
// found it.
type vaultToRemove struct {
// dir is the vault's directory, which holds all its secrets.
dir string
secrets int
// vaults lists every vault, this one included, and isCurrent is set
// when this one is the current vault.
vaults []string
isCurrent bool
// question names what is removed, for the user to confirm.
question string
}
// findVaultToRemove checks that the vault exists and is not the last one,
// and counts its secrets.
func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err)
}
if !slices.Contains(vaults, name) {
return vaultToRemove{},
fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
}
if len(vaults) == 1 {
return vaultToRemove{}, errCannotRemoveLastVault
}
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get current vault: %w", err)
}
vaultDir, err := vault.NewVault(cli.fs, cli.stateDir, name).GetDirectory()
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
}
secrets, err := cli.countVaultSecrets(vaultDir)
if err != nil {
return vaultToRemove{}, err
}
return vaultToRemove{
dir: vaultDir,
secrets: secrets,
vaults: vaults,
isCurrent: currentVault.GetName() == name,
question: fmt.Sprintf(
"Permanently remove vault '%s' and its %d secret(s)?",
name, secrets),
}, nil
}
+410
View File
@@ -0,0 +1,410 @@
package cli
import (
"errors"
"fmt"
"io"
"log"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"filippo.io/age"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
tabWriterPadding = 2
)
// Sentinel errors for version operations
var (
errCannotRemoveCurrentVersion = errors.New("promote another version first")
)
// newVersionCmd returns the version management command
func newVersionCmd() *cobra.Command {
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return VersionCommands(cli)
}
// VersionCommands returns the version management commands
func VersionCommands(cli *Instance) *cobra.Command {
versionCmd := &cobra.Command{
Use: "version",
Short: "Manage secret versions",
Long: "Commands for managing secret versions including listing, " +
"promoting, and retrieving specific versions",
}
// List versions command
listCmd := &cobra.Command{
Use: "list <secret-name>",
Aliases: []string{"ls"},
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ListVersions(cmd, args[0])
},
}
// Promote version command
promoteCmd := &cobra.Command{
Use: "promote <secret-name> <version>",
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified " +
"version without modifying timestamps",
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// Version number completion for the second arg is not implemented
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(cmd, args[0], args[1])
},
}
// Remove version command
removeCmd := &cobra.Command{
Use: "remove <secret-name> <version>",
Aliases: []string{"rm"},
Short: "Remove a specific version of a secret",
Long: "Remove a specific version of a secret. Cannot remove the " +
"current version. Asks for confirmation first; when stdin " +
"is not a terminal, fails unless --force is given.",
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// Version number completion for the second arg is not implemented
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
return cli.RemoveVersion(cmd, args[0], args[1], force)
},
}
removeCmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation")
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
return versionCmd
}
// ListVersions lists all versions of a secret
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
secret.Debug("ListVersions called", "secret_name", secretName)
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
secret.Debug("Failed to get current vault", "error", err)
return err
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory", "error", err)
return err
}
// Get the encoded secret name
encodedName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists", "error", err)
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
secret.Debug("Secret not found", "secret_name", secretName)
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
}
// List all versions
versions, err := secret.ListVersions(cli.fs, secretDir)
if err != nil {
secret.Debug("Failed to list versions", "error", err)
return fmt.Errorf("failed to list versions: %w", err)
}
if len(versions) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
return nil
}
// Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err)
currentVersion = ""
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Create table writer
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
// Load and display each version's metadata
for _, version := range versions {
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
}
_ = w.Flush()
return nil
}
// printVersionRow loads one version's metadata and writes its table row
func printVersionRow(
w io.Writer, vlt *vault.Vault,
secretName, version, currentVersion string,
ltIdentity *age.X25519Identity,
) {
sv := secret.NewVersion(vlt, secretName, version)
// Load metadata
err := sv.LoadMetadata(ltIdentity)
if err != nil {
secret.Warn("Failed to load version metadata",
"version", version, "error", err)
// Display version with error
status := "error"
if version == currentVersion {
status = "current (error)"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
return
}
// Determine status
status := "expired"
if version == currentVersion {
status = "current"
}
// Format timestamps
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
notBefore := formatVersionTime(sv.Metadata.NotBefore)
notAfter := formatVersionTime(sv.Metadata.NotAfter)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
version, createdAt, status, notBefore, notAfter)
}
// formatVersionTime formats an optional version timestamp, "-" when unset
func formatVersionTime(t *time.Time) string {
if t == nil {
return "-"
}
return t.Format("2006-01-02 15:04:05")
}
// PromoteVersion promotes a specific version to current
func (cli *Instance) PromoteVersion(
cmd *cobra.Command, secretName string, version string,
) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return err
}
// Get the encoded secret name
encodedName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if version exists
exists, err := secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, secretName)
}
// Update the current symlink using the proper function
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to update current version: %w", err)
}
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
return nil
}
// RemoveVersion removes a specific version of a secret, after asking the
// user to confirm unless force is set.
func (cli *Instance) RemoveVersion(
cmd *cobra.Command, secretName string, version string, force bool,
) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
var found versionToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVersionToRemove(secretName, version)
return found.question, err
})
if err != nil {
return err
}
defer release()
err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil {
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
}
// versionToRemove is what removing a version removes, as
// findVersionToRemove found it.
type versionToRemove struct {
// dir is the version's directory.
dir string
// question names what is removed, for the user to confirm.
question string
}
// findVersionToRemove checks that the version exists in the secret in the
// current vault and is not its current version.
func (cli *Instance) findVersionToRemove(
secretName, version string,
) (versionToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return versionToRemove{}, err
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return versionToRemove{}, err
}
// Get the encoded secret name
encodedName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return versionToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return versionToRemove{},
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
}
// Check if version exists
exists, err = secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return versionToRemove{},
fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, secretName)
}
// Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil {
return versionToRemove{},
fmt.Errorf("failed to get current version: %w", err)
}
// Don't allow removing the current version
if version == currentVersion {
return versionToRemove{}, fmt.Errorf(
"cannot remove the current version '%s'; %w",
version, errCannotRemoveCurrentVersion)
}
return versionToRemove{
dir: filepath.Join(secretDir, "versions", version),
question: fmt.Sprintf("Permanently remove version %s of secret "+
"'%s' from vault '%s'?", version, secretName, vlt.GetName()),
}, nil
}
+414
View File
@@ -0,0 +1,414 @@
// Version CLI Command Tests
//
// Tests for version-related CLI commands:
//
// - TestListVersionsCommand: Tests `secret version list` command output
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
// - TestPromoteVersionCommand: Tests `secret version promote` command
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestVersionCommandStructure: Tests command structure and help text
// - TestListVersionsEmptyOutput: Tests edge case with no versions
//
// Test Utilities:
// - setupTestVault(): CLI test helper for vault initialization
// - Uses consistent test mnemonic for reproducible testing
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/awnumar/memguard"
"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"
"sneak.berlin/go/secret/pkg/agehd"
)
const (
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// testStateDir is the in-memory state directory used by CLI tests.
testStateDir = "/test/state"
)
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// Helper function to add a version of the "test/secret" secret to the
// vault with proper buffer protection
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
t.Helper()
buffer := memguard.NewBufferFromBytes(value)
defer buffer.Destroy()
err := vlt.AddSecret("test/secret", buffer, force)
require.NoError(t, err)
}
// Helper function to set up a vault with long-term key in testStateDir
func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default",
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vlt.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Select vault
err = vault.SelectVault(fs, testStateDir, "default")
require.NoError(t, err)
}
func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
// List versions
err = cli.ListVersions(cmd, "test/secret")
require.NoError(t, err)
// Read output
outputStr := buf.String()
// Verify output contains version headers
assert.Contains(t, outputStr, "VERSION")
assert.Contains(t, outputStr, "CREATED")
assert.Contains(t, outputStr, "STATUS")
assert.Contains(t, outputStr, "NOT_BEFORE")
assert.Contains(t, outputStr, "NOT_AFTER")
// Should have current status for latest version
assert.Contains(t, outputStr, "current")
// Should have two version entries
lines := strings.Split(outputStr, "\n")
versionLines := 0
for _, line := range lines {
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
versionLines++
}
}
assert.Equal(t, 2, versionLines)
}
func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
// Try to list versions of non-existent secret
err := cli.ListVersions(cmd, "nonexistent/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound)
}
func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Current should be version-2
value, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
// Promote first version
firstVersion := versions[1] // Older version
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
err = cli.PromoteVersion(cmd, "test/secret", firstVersion)
require.NoError(t, err)
// Read output
outputStr := buf.String()
// Verify success message
assert.Contains(t, outputStr, "Promoted version")
assert.Contains(t, outputStr, firstVersion)
// Verify current is now version-1
promoted, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
defer promoted.Destroy()
assert.Equal(t, []byte("version-1"), promoted.Bytes())
}
func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("value"), false)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
// Try to promote non-existent version
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
require.ErrorIs(t, err, vault.ErrVersionNotFound)
}
func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
// Test getting the current version
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, "version-2", buf.String())
// Test getting specific version
buf.Reset()
firstVersion := versions[1] // Older version
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
require.NoError(t, err)
assert.Equal(t, "version-1", buf.String())
}
func TestGetSecretWritesBinaryValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
require.False(t, utf8.Valid(value))
// A copy, since storing a value wipes the slice it came from
addTestSecret(t, vlt, bytes.Clone(value), false)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 1)
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
// Each writes exactly the stored bytes, with no trailing newline
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
buf.Reset()
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
}
//nolint:paralleltest // reads process environment to determine the state dir
func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cmd := VersionCommands(cli)
assert.Equal(t, "version", cmd.Use)
assert.Equal(t, "Manage secret versions", cmd.Short)
// Check subcommands
listCmd := cmd.Commands()[0]
assert.Equal(t, "list <secret-name>", listCmd.Use)
assert.Equal(t, "List all versions of a secret", listCmd.Short)
promoteCmd := cmd.Commands()[1]
assert.Equal(t, "promote <secret-name> <version>", promoteCmd.Use)
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
}
func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Create a secret directory without versions (edge case)
vaultDir := stateDir + "/vaults.d/default"
secretDir := vaultDir + "/secrets.d/test%secret"
err := fs.MkdirAll(secretDir, 0o755)
require.NoError(t, err)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
// List versions - should show "No versions found"
err = cli.ListVersions(cmd, "test/secret")
// Should succeed even with no versions
assert.NoError(t, err)
}
+172
View File
@@ -0,0 +1,172 @@
package secret
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/afero"
)
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
const tempNamePart = ".tmp-"
// WriteFileAtomic replaces the file at path with data so that a reader, or
// a crash at any moment, finds either the old content or the new, never a
// partial file. The data goes into a temporary file that afero.TempFile
// creates with mode 0600 in the same directory (a rename is only atomic
// within one filesystem), is synced to disk, and is renamed over path. The
// temporary file is removed if any step fails.
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
tmp, err := afero.TempFile(fs, filepath.Dir(path),
"."+filepath.Base(path)+tempNamePart+"*")
if err != nil {
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
}
_, err = tmp.Write(data)
if err == nil {
err = tmp.Sync()
}
closeErr := tmp.Close()
if err == nil {
err = closeErr
}
if err == nil {
err = fs.Rename(tmp.Name(), path)
}
if err != nil {
_ = fs.Remove(tmp.Name())
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
// TempDirFor creates an empty temporary directory in which to build the
// directory target before renaming it into place, or into which to move
// target before deleting it. It is made in target's grandparent: on the
// same filesystem, so the rename is atomic, and outside target's parent,
// the directory that is listed to find vaults, secrets, versions and
// unlockers, so one left behind by a crash is never taken for one of them.
// Its name leaves out target's, which may already be as long as a file name
// can be.
func TempDirFor(fs afero.Fs, target string) (string, error) {
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
if err != nil {
return "", fmt.Errorf(
"failed to create temporary directory for %s: %w", target, err)
}
return dir, nil
}
// RemoveLeftovers deletes from dir the temporary files of WriteFileAtomic
// and the temporary directories of TempDirFor that a command killed
// part-way left there: each entry whose name starts with "." and holds
// tempNamePart. The caller must hold the state directory lock, so that no
// running command is still using one. A dir that does not exist holds none.
func RemoveLeftovers(fs afero.Fs, dir string) error {
entries, err := afero.ReadDir(fs, dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("failed to read %s: %w", dir, err)
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, ".") || !strings.Contains(name, tempNamePart) {
continue
}
path := filepath.Join(dir, name)
err = fs.RemoveAll(path)
if err != nil {
return fmt.Errorf("failed to remove %s: %w", path, err)
}
Debug("Removed what an interrupted command left", "path", path)
}
return nil
}
// WriteDir calls write to write the files of the new directory dir into a
// temporary directory from TempDirFor, which is then renamed to dir, so that
// neither a failure nor a crash leaves dir half-written; on a failure the
// temporary directory is removed, and a failure to remove it is returned
// along with the first. A directory cannot be replaced in one rename, so if
// dir already exists, WriteDir fails without calling write.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir)
if err != nil {
return fmt.Errorf("failed to check for %s: %w", dir, err)
}
if exists {
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
}
// Create the directory the finished one is renamed into
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
}
tmp, err := TempDirFor(fs, dir)
if err != nil {
return err
}
err = write(tmp)
if err == nil {
err = fs.Rename(tmp, dir)
}
if err != nil {
removeErr := fs.RemoveAll(tmp)
if removeErr != nil {
err = errors.Join(err,
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
}
return err
}
return nil
}
// RemoveDirAtomic deletes the directory dir so that it disappears in one
// rename: dir is moved into a new directory from TempDirFor, which is then
// deleted. A crash part-way leaves only that temporary directory behind.
func RemoveDirAtomic(fs afero.Fs, dir string) error {
tmp, err := TempDirFor(fs, dir)
if err != nil {
return err
}
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
if err != nil {
_ = fs.Remove(tmp)
return fmt.Errorf("failed to remove %s: %w", dir, err)
}
err = fs.RemoveAll(tmp)
if err != nil {
return fmt.Errorf("failed to remove %s: %w", dir, err)
}
return nil
}
+901
View File
@@ -0,0 +1,901 @@
package secret_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"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"
)
var errInjected = errors.New("injected failure")
// The kinds of change hookFs passes to before.
const (
opCreate = "create"
opOpen = "open"
opSync = "sync"
opMkdir = "mkdir"
opRemove = "remove"
opRename = "rename"
)
// currentFile is the file in a secret's directory that names its current
// version.
const currentFile = "current"
// unlockerMetadataFile is the file a new unlocker writes last.
const unlockerMetadataFile = "unlocker-metadata.json"
// privKeyFile is the file that holds the encrypted private key of a version
// or of a passphrase unlocker.
const privKeyFile = "priv.age"
// unlockerPassphrase protects the passphrase unlockers the tests create.
//
//nolint:gosec // G101: test data, not a real credential
const unlockerPassphrase = "unlocker passphrase"
// hookFs passes every call through to Fs, but first calls before for each
// call that changes the filesystem, and for each Sync of a file opened
// through it, with the path it changes (the new path, for Rename). A test
// uses before to inspect the tree at every point where a crash could stop
// the code under test, or returns an error from it to make that call fail.
// If opened is set, OpenFile also tells it the mode it opens each file with.
type hookFs struct {
afero.Fs
before func(op, path string) error
opened func(path string, perm os.FileMode)
}
// hookFile is a file opened through hookFs.
type hookFile struct {
afero.File
before func(op, path string) error
}
func (f hookFile) Sync() error {
err := f.before(opSync, f.Name())
if err != nil {
return err
}
return f.File.Sync()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
err := h.before(opCreate, name)
if err != nil {
return nil, err
}
file, err := h.Fs.Create(name)
if err != nil {
return nil, err
}
return hookFile{File: file, before: h.before}, nil
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
err := h.before(opOpen, name)
if err != nil {
return nil, err
}
if h.opened != nil {
h.opened(name, perm)
}
file, err := h.Fs.OpenFile(name, flag, perm)
if err != nil {
return nil, err
}
return hookFile{File: file, before: h.before}, nil
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
err := h.before(opMkdir, name)
if err != nil {
return err
}
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
err := h.before(opMkdir, path)
if err != nil {
return err
}
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
err := h.before(opRemove, name)
if err != nil {
return err
}
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
err := h.before(opRemove, path)
if err != nil {
return err
}
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
err := h.before(opRename, newname)
if err != nil {
return err
}
return h.Fs.Rename(oldname, newname)
}
// testFilesystem is a filesystem to run a test on, with a directory in it
// to work in.
type testFilesystem struct {
name string
open func(t *testing.T) (afero.Fs, string)
}
// testFilesystems are the in-memory filesystem that most tests use and the
// real one: every rename-based guarantee is checked on both.
//
//nolint:gochecknoglobals // read-only table shared by the tests below
var testFilesystems = []testFilesystem{
{"memory", func(*testing.T) (afero.Fs, string) {
return afero.NewMemMapFs(), "/test"
}},
{"real", func(t *testing.T) (afero.Fs, string) {
t.Helper()
return afero.NewOsFs(), t.TempDir()
}},
}
// dirNames lists the names in dir.
func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
t.Helper()
entries, err := afero.ReadDir(fs, dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names
}
// dirFiles returns the contents of the files in dir, by name.
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
files := map[string]string{}
for _, name := range dirNames(t, fs, dir) {
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
require.NoError(t, err)
files[name] = string(data)
}
return files
}
// writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it.
func writeLongTermKey(
t *testing.T, fs afero.Fs, stateDir string,
) *age.X25519Identity {
t.Helper()
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
vaultDir, err := vault.GetDirectory()
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
[]byte(ltIdentity.Recipient().String()), 0o600))
return ltIdentity
}
// newVaultWithSecret creates the vault name under stateDir from the test
// mnemonic, with a secret "shared" in it that holds value.
func newVaultWithSecret(
t *testing.T, fs afero.Fs, stateDir, name, value string,
) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value))
defer buffer.Destroy()
require.NoError(t, vlt.AddSecret("shared", buffer, false))
return vlt
}
func TestWriteFileAtomicReplacesFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
path := filepath.Join(dir, currentFile)
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
assert.Equal(t, "new", string(data))
info, err := fs.Stat(path)
require.NoError(t, err)
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
// No temporary file is left next to it
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
})
}
}
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
path := filepath.Join(dir, currentFile)
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
fs := hookFs{Fs: base, before: func(op, _ string) error {
if op == opRename {
return errInjected
}
return nil
}}
err := secret.WriteFileAtomic(fs, path, []byte("new"))
require.ErrorIs(t, err, errInjected)
data, err := afero.ReadFile(base, path)
require.NoError(t, err)
assert.Equal(t, "old", string(data))
// The temporary file is removed again
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
})
}
}
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
// directory stands, which a crash could stop half-way, and that it leaves
// nothing behind.
func TestRemoveDirAtomic(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
listed := filepath.Join(dir, "secrets.d")
target := filepath.Join(listed, "doomed")
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
require.NoError(t, secret.WriteFileAtomic(base,
filepath.Join(target, currentFile), []byte("20231216.001")))
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRemove && strings.HasPrefix(path, target) {
t.Errorf("deleted %s where it stands", path)
}
return nil
}}
require.NoError(t, secret.RemoveDirAtomic(fs, target))
// Gone, and no temporary directory is left in the directory
// that is listed or in the one above it
assert.Empty(t, dirNames(t, base, listed))
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
})
}
}
// TestLongestNames adds a secret to a vault and removes the vault, both
// named with 255 bytes, the most a file name may have, on the real
// filesystem: the temporary directories they use must fit that limit too.
func TestLongestNames(t *testing.T) {
t.Parallel()
const longestName = 255
fs := afero.NewOsFs()
name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret(name, value, false))
got, err := vlt.GetSecret(name)
require.NoError(t, err)
defer got.Destroy()
assert.Equal(t, []byte("long"), got.Bytes())
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
assert.NoDirExists(t, vaultDir)
}
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
// another vault, as a forced move between vaults does, and makes the last
// step that completes the copy fail. The secret it was to replace must
// still be there unchanged: it may go only once its replacement is whole.
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
// The copy is complete once its current file is written
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(path) == currentFile {
return errInjected
}
return nil
}}
err := vault.NewVault(fs, stateDir, "dest").
CopySecretAllVersions(src, "shared", "shared", true)
require.ErrorIs(t, err, errInjected)
value, err := dest.GetSecret("shared")
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("old"), value.Bytes())
})
}
}
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
// secret over another and removes one, and checks that none of them makes a
// directory directly in secrets.d or in a versions directory. Those are
// listed to find secrets and versions, so a temporary directory made there
// would be listed while half-built, and one left by a crash would stay.
func TestTempDirsStayOutOfListings(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first")
fs := hookFs{Fs: base, before: func(op, path string) error {
parent := filepath.Base(filepath.Dir(path))
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
t.Errorf("made %s where it is listed", path)
}
return nil
}}
vlt := vault.NewVault(fs, stateDir, "default")
vlt.Mnemonic = testMnemonicBuffer(t)
value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("shared", value, true))
require.NoError(t, vlt.AddSecret("other", value, false))
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
require.NoError(t, secret.RemoveDirAtomic(fs,
filepath.Join(vaultDir, "secrets.d", "shared")))
})
}
}
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
// once after it returns, that the version directory either does not exist
// or holds all of its files: a crash at any point leaves no version that
// cannot be decrypted.
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
ltIdentity := writeLongTermKey(t, base, stateDir)
var versionDir string
checkVersionDir := func(string, string) error {
exists, err := afero.DirExists(base, versionDir)
require.NoError(t, err)
if exists {
assert.ElementsMatch(t,
[]string{"pub.age", "value.age", privKeyFile, "metadata.age"},
dirNames(t, base, versionDir),
"version directory visible before it was complete")
}
return nil
}
fs := hookFs{Fs: base, before: checkVersionDir}
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
versionDir = sv.Directory
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
defer value.Destroy()
require.NoError(t, sv.Save(value))
require.NoError(t, checkVersionDir("", ""))
got, err := sv.GetValue(ltIdentity)
require.NoError(t, err)
defer got.Destroy()
assert.Equal(t, "whole or nothing", got.String())
})
}
}
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
// private key fail, after the value has been written, and checks that
// neither the version nor its temporary directory is left behind.
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
writeLongTermKey(t, base, stateDir)
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(path) == privKeyFile {
return errInjected
}
return nil
}}
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
value := memguard.NewBufferFromBytes([]byte("never stored"))
defer value.Destroy()
require.ErrorIs(t, sv.Save(value), errInjected)
// The secret directory holds only the empty versions directory
versionsDir := filepath.Dir(sv.Directory)
assert.Equal(t, []string{"versions"},
dirNames(t, base, filepath.Dir(versionsDir)))
assert.Empty(t, dirNames(t, base, versionsDir))
})
}
}
// TestCurrentFilesNeverMissing selects the current version, vault and
// unlocker again and checks, before each change this makes, that the file
// naming the current one exists: a reader or a crash never finds it
// missing.
func TestCurrentFilesNeverMissing(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
// Created as the current unlocker
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
version, err := secret.GetCurrentVersion(base, secretDir)
require.NoError(t, err)
for _, tc := range []struct {
path string
reselect func(fs afero.Fs) error
}{
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
return secret.SetCurrentVersion(fs, secretDir, version)
}},
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
return vault.SelectVault(fs, stateDir, testVaultName)
}},
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
return vault.NewVault(fs, stateDir, testVaultName).
SelectUnlocker(unlocker.GetID())
}},
} {
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.Exists(base, tc.path)
require.NoError(t, err)
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
return nil
}}
require.NoError(t, tc.reselect(fs))
}
})
}
}
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
// temporary file with mode 0600, rather than wider and narrowed later, so
// that no other user can ever read it, and syncs it before renaming it into
// place, so that a crash cannot leave the file named but its data lost.
func TestWriteFileAtomicTempFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
var modes []os.FileMode
synced := false
fs := hookFs{
Fs: base,
before: func(op, _ string) error {
switch op {
case opSync:
synced = true
case opRename:
assert.True(t, synced, "renamed before syncing")
}
return nil
},
opened: func(_ string, perm os.FileMode) {
modes = append(modes, perm)
},
}
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(dir, currentFile), []byte("new")))
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
})
}
}
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
// vault whose long-term key cannot be had: it must fail without writing
// anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
t.Parallel()
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
t.Errorf("changed %s before getting the long-term key", path)
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
require.Error(t, err)
}
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
// creating a passphrase unlocker makes, that the unlocker's directory either
// does not exist or holds all of its files: a crash or a failure at any point
// leaves no partial unlocker.
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
t.Parallel()
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
// The vault has no unlocker yet, so any directory in here is
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error {
for _, name := range dirNames(t, base, unlockersDir) {
assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
"unlocker directory visible before it was complete")
}
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
})
}
}
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
// passphrase unlocker twice, each time with only the current unlocker to open
// the vault. The first replacement fails right after making the new unlocker
// current, so the old one is not removed. The second checks, before every
// change it makes, that the vault opens with the passphrase through its
// current unlocker, which is what a crash at that change would leave; once it
// returns, the vault must have one passphrase unlocker left.
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Every change after the switch to the new unlocker fails
switched := false
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
if switched {
return errInjected
}
switched = op == opRename && path == currentUnlockerPath
return nil
}}
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.ErrorIs(t, err, errInjected)
unlockers, err := vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
checked := hookFs{Fs: base, before: func(string, string) error {
assertOpens()
return nil
}}
replacing = vault.NewVault(checked, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assertOpens()
unlockers, err = vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 1)
})
}
}
// vaultOpensCheck returns a function that checks that the test vault under
// stateDir opens through its current unlocker, with passphrase, to the
// long-term key ltIdentity. Opening it takes a second, so an unlocker
// directory it has opened through before is not opened again: it must hold
// the same files as then.
func vaultOpensCheck(
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) func() {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
// The files of each unlocker directory the vault has opened through
opened := map[string]map[string]string{}
return func() {
t.Helper()
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
if before, ok := opened[string(current)]; ok {
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
return
}
opener := vault.NewVault(fs, stateDir, testVaultName)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
opened[string(current)] = files
}
}
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor
// its temporary directory is left behind; and, when the temporary directory
// cannot be removed either, that both failures are reported.
func TestWriteDirFailureLeavesNothing(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
listed := filepath.Join(dir, "unlockers.d")
target := filepath.Join(listed, "new")
writeThenFail := func(tmp string) error {
require.NoError(t, secret.WriteFileAtomic(base,
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
return errInjected
}
err := secret.WriteDir(base, target, writeThenFail)
require.ErrorIs(t, err, errInjected)
// Nothing in the directory that is listed, nor beside it
assert.Empty(t, dirNames(t, base, listed))
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
fs := hookFs{Fs: base, before: func(op, _ string) error {
if op == opRemove {
return os.ErrPermission
}
return nil
}}
err = secret.WriteDir(fs, target, writeThenFail)
require.ErrorIs(t, err, errInjected)
require.ErrorIs(t, err, os.ErrPermission)
assert.Empty(t, dirNames(t, base, listed))
})
}
}
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// write, when the directory already exists, and leaves the directory as it
// was: it never writes into a directory in place.
func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(string) error {
t.Error("write called for a directory that exists")
return nil
})
require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
})
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// Package secret provides core types and constants for the secret application.
package secret
import "os"
const (
// AppID is the unique identifier for this application
AppID = "berlin.sneak.pkg.secret"
// EnvStateDir is the environment variable for specifying the state directory
EnvStateDir = "SB_SECRET_STATE_DIR"
// EnvMnemonic is the environment variable for providing the mnemonic phrase
EnvMnemonic = "SB_SECRET_MNEMONIC"
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
//nolint:gosec // G101: env var name, not a credential
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the GPG key ID
EnvGPGKeyID = "SB_GPG_KEY_ID"
// TerminalPath opens the controlling terminal of the process that opens
// it, where the passphrase and mnemonic prompts ask.
TerminalPath = "/dev/tty"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory. It runs to the nanosecond, so that every new
// unlocker, even one added right after another, gets a directory of its
// own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
)
// File system permission constants
const (
// DirPerms is the permission used for directories (read-write-execute for owner only)
DirPerms os.FileMode = 0o700
// FilePerms is the permission used for sensitive files (read-write for owner only)
FilePerms os.FileMode = 0o600
)
+174 -53
View File
@@ -2,35 +2,92 @@ package secret
import (
"bytes"
"errors"
"fmt"
"io"
"syscall"
"os"
"unsafe"
"filippo.io/age"
"github.com/awnumar/memguard"
"golang.org/x/term"
)
// encryptToRecipient encrypts data to a recipient using age
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errNoTerminal = errors.New("there is no terminal to ask on")
errNothingEntered = errors.New("nothing was entered")
)
// 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
// The data parameter should be a LockedBuffer for secure memory handling
func EncryptToRecipient(
data *memguard.LockedBuffer, recipient age.Recipient,
) ([]byte, error) {
if data == nil {
return nil, errNilDataBuffer
}
Debug("EncryptToRecipient starting", "data_length", data.Size())
var buf bytes.Buffer
Debug("Creating age encryptor")
w, err := age.Encrypt(&buf, recipient)
if err != nil {
Debug("Failed to create encryptor", "error", err)
return nil, fmt.Errorf("failed to create encryptor: %w", err)
}
if _, err := w.Write(data); err != nil {
Debug("Created age encryptor successfully")
Debug("Writing data to encryptor")
_, err = w.Write(data.Bytes())
if err != nil {
Debug("Failed to write data to encryptor", "error", err)
return nil, fmt.Errorf("failed to write data: %w", err)
}
if err := w.Close(); err != nil {
Debug("Wrote data to encryptor successfully")
Debug("Closing encryptor")
err = w.Close()
if err != nil {
Debug("Failed to close encryptor", "error", err)
return nil, fmt.Errorf("failed to close encryptor: %w", err)
}
return buf.Bytes(), nil
Debug("Closed encryptor successfully")
result := buf.Bytes()
Debug("EncryptToRecipient completed successfully", "result_length", len(result))
return result, nil
}
// decryptWithIdentity decrypts data with an identity using age
func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
// DecryptWithIdentity decrypts data with an identity using age
func DecryptWithIdentity(
data []byte, identity age.Identity,
) (*memguard.LockedBuffer, error) {
r, err := age.Decrypt(bytes.NewReader(data), identity)
if err != nil {
return nil, fmt.Errorf("failed to create decryptor: %w", err)
@@ -41,72 +98,136 @@ func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
}
return result, nil
// Create a secure buffer for the decrypted data
resultBuffer := memguard.NewBufferFromBytes(result)
// Zero out the original slice to prevent plaintext from lingering
// in unprotected memory
for i := range result {
result[i] = 0
}
return resultBuffer, nil
}
// encryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
func encryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
recipient, err := age.NewScryptRecipient(passphrase)
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
// a new locked buffer. The caller must destroy it.
//
// This is best effort. age gives the key only as a string in ordinary memory.
// The bytes of that string are moved into the buffer, which overwrites them,
// although Go otherwise never changes a string; nothing else holds this one.
// The copies age makes while building the string are left in ordinary memory.
// Avoiding those would mean encoding the key here, straight into the buffer.
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
key := id.String()
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
return memguard.NewBufferFromBytes(keyBytes)
}
// EncryptWithPassphrase encrypts data using a passphrase with age's
// scrypt-based encryption. Both data and passphrase parameters should
// be LockedBuffers for secure memory handling
func EncryptWithPassphrase(
data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
) ([]byte, error) {
if data == nil {
return nil, errNilDataBuffer
}
if passphrase == nil {
return nil, errNilPassphraseBuffer
}
// Create recipient directly from passphrase - unavoidable string
// conversion due to age API
recipient, err := age.NewScryptRecipient(passphrase.String())
if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
}
return encryptToRecipient(data, recipient)
if ScryptWorkFactor != 0 {
recipient.SetWorkFactor(ScryptWorkFactor)
}
return EncryptToRecipient(data, recipient)
}
// decryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
identity, err := age.NewScryptIdentity(passphrase)
// DecryptWithPassphrase decrypts data using a passphrase with age's
// scrypt-based decryption. The passphrase parameter should be a
// LockedBuffer for secure memory handling
func DecryptWithPassphrase(
encryptedData []byte, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if passphrase == nil {
return nil, errNilPassphraseBuffer
}
// Create identity directly from passphrase - unavoidable string
// conversion due to age API
identity, err := age.NewScryptIdentity(passphrase.String())
if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
}
return decryptWithIdentity(encryptedData, identity)
return DecryptWithIdentity(encryptedData, identity)
}
// 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
func readPassphrase(prompt string) (string, error) {
// Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal - fall back to regular input
fmt.Print(prompt)
var passphrase string
_, err := fmt.Scanln(&passphrase)
if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err)
}
return passphrase, nil
}
// Terminal input - use secure password reading
fmt.Print(prompt)
passphrase, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err)
}
fmt.Println() // Print newline since ReadPassword doesn't echo
if len(passphrase) == 0 {
return "", fmt.Errorf("passphrase cannot be empty")
}
return string(passphrase), nil
// Returns a LockedBuffer containing the passphrase for secure memory handling.
// Every error it returns wraps ErrPassphraseNotRead.
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
}
// decryptSecretWithLongTermKey is a helper that parses a long-term private key and uses it to decrypt secret data
func decryptSecretWithLongTermKey(ltPrivKeyData []byte, encryptedData []byte) ([]byte, error) {
// Parse long-term private key
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
// 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 asks on the controlling terminal and reads the answer
// there without echoing it, whatever stdin is: in
// `echo value | secret add name --force`, stdin is a pipe. 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) {
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
Debug("Failed to open the controlling terminal", "error", err)
return nil, fmt.Errorf(
"%w: %w. Please set the %s environment variable or run interactively",
notRead, errNoTerminal, envVar)
}
defer func() { _ = terminal.Close() }()
_, _ = fmt.Fprint(terminal, prompt)
// Decrypt secret data using long-term key
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
input, err := term.ReadPassword(int(terminal.Fd()))
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
return nil, fmt.Errorf("%w: %w", notRead, err)
}
// The newline typed after the input, which ReadPassword did not echo
_, _ = fmt.Fprintln(terminal)
if len(input) == 0 {
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
}
// Create a secure buffer and copy the input
secureBuffer := memguard.NewBufferFromBytes(input)
// Clear the original input slice
for i := range input {
input[i] = 0
}
return decryptedData, nil
return secureBuffer, nil
}
+29
View File
@@ -0,0 +1,29 @@
package secret_test
import (
"testing"
"filippo.io/age"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
// private key, and that the identity still gives that key afterwards: the
// helper overwrites the string age returned, so age must not keep it.
func TestIdentityToLockedBuffer(t *testing.T) {
t.Parallel()
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
buffer := secret.IdentityToLockedBuffer(identity)
defer buffer.Destroy()
parsed, err := age.ParseX25519Identity(buffer.String())
require.NoError(t, err)
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
assert.Equal(t, identity.String(), buffer.String())
}
+165
View File
@@ -0,0 +1,165 @@
package secret
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
"syscall"
"golang.org/x/term"
)
var (
debugEnabled bool //nolint:gochecknoglobals // package debug state
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
)
//nolint:gochecknoinits // debug logging must be ready before any package use
func init() {
InitDebugLogging()
}
// InitDebugLogging initializes the debug logging system based on the
// current GODEBUG environment variable
func InitDebugLogging() {
godebug := os.Getenv("GODEBUG")
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
if !debugEnabled {
// Create a no-op logger that discards all output
debugLogger = slog.New(slog.DiscardHandler)
return
}
// Disable stderr buffering for immediate debug output when
// debugging is enabled
//nolint:dogsled // syscall.Syscall returns three values, none needed
_, _, _ = syscall.Syscall(
syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
// Check if STDERR is a TTY
isTTY := term.IsTerminal(syscall.Stderr)
var handler slog.Handler
if isTTY {
// TTY output: colorized structured format
handler = newColorizedHandler(os.Stderr)
} else {
// Non-TTY output: JSON Lines format
handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
}
debugLogger = slog.New(handler)
}
// IsDebugEnabled returns true if debug logging is enabled
func IsDebugEnabled() bool {
return debugEnabled
}
// Warn logs a warning message to stderr unconditionally (visible
// without --verbose or debug flags)
func Warn(msg string, args ...any) {
var output strings.Builder
output.WriteString("WARNING: " + msg)
for i := 0; i+1 < len(args); i += 2 {
fmt.Fprintf(&output, " %s=%v", args[i], args[i+1])
}
output.WriteString("\n")
fmt.Fprint(os.Stderr, output.String())
}
// Debug logs a debug message with optional attributes
func Debug(msg string, args ...any) {
if !debugEnabled {
return
}
debugLogger.Debug(msg, args...)
}
// Debugf logs a formatted debug message with optional attributes
func Debugf(format string, args ...any) {
if !debugEnabled {
return
}
debugLogger.Debug(fmt.Sprintf(format, args...))
}
// DebugWith logs a debug message with structured attributes
func DebugWith(msg string, attrs ...slog.Attr) {
if !debugEnabled {
return
}
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
}
// colorizedHandler implements a TTY-friendly structured log handler
type colorizedHandler struct {
output io.Writer
}
func newColorizedHandler(output io.Writer) slog.Handler {
return &colorizedHandler{output: output}
}
func (h *colorizedHandler) Enabled(_ context.Context, level slog.Level) bool {
// Explicitly check that debug is enabled AND the level is DEBUG or higher
// This ensures we don't default to INFO level when debug is enabled
return debugEnabled && level >= slog.LevelDebug
}
func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
if !debugEnabled {
return nil
}
// Format: [DEBUG] message {key=value, key2=value2}
output := fmt.Sprintf("\033[36m[DEBUG]\033[0m \033[1m%s\033[0m", record.Message)
if record.NumAttrs() > 0 {
output += " \033[33m{"
first := true
record.Attrs(func(attr slog.Attr) bool {
if !first {
output += ", "
}
first = false
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
return true
})
output += "}\033[0m"
}
output += "\n"
_, err := h.output.Write([]byte(output))
return err
}
func (h *colorizedHandler) WithAttrs(_ []slog.Attr) slog.Handler {
// For simplicity, return the same handler
// In a more complex implementation, we'd create a new handler with the attrs
return h
}
func (h *colorizedHandler) WithGroup(_ string) slog.Handler {
// For simplicity, return the same handler
// In a more complex implementation, we'd create a new handler with the group
return h
}
+124
View File
@@ -0,0 +1,124 @@
//nolint:testpackage // white-box test of unexported debug internals
package secret
import (
"bytes"
"log/slog"
"strings"
"syscall"
"testing"
"golang.org/x/term"
)
func TestDebugLogging(t *testing.T) {
// Test cleanup handled by t.Setenv
defer InitDebugLogging() // Re-initialize debug system after test
tests := []struct {
name string
godebug string
expectEnabled bool
}{
{
name: "debug enabled",
godebug: "berlin.sneak.pkg.secret",
expectEnabled: true,
},
{
name: "debug enabled with other flags",
godebug: "other=1,berlin.sneak.pkg.secret,another=value",
expectEnabled: true,
},
{
name: "debug disabled",
godebug: "other=1",
expectEnabled: false,
},
{
name: "debug disabled empty",
godebug: "",
expectEnabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set GODEBUG
if tt.godebug != "" {
t.Setenv("GODEBUG", tt.godebug)
}
// Re-initialize debug system
InitDebugLogging()
// Test if debug is enabled
enabled := IsDebugEnabled()
if enabled != tt.expectEnabled {
t.Errorf("IsDebugEnabled() = %v, want %v", enabled, tt.expectEnabled)
}
// If debug should be enabled, test that debug output works
if tt.expectEnabled {
// Capture debug output by redirecting the colorized handler
var buf bytes.Buffer
// Override the debug logger for testing
oldLogger := debugLogger
if term.IsTerminal(syscall.Stderr) {
// TTY: use colorized handler with our buffer
debugLogger = slog.New(newColorizedHandler(&buf))
} else {
// Non-TTY: use JSON handler with our buffer
debugLogger = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
// Test debug output
Debug("test message", "key", "value")
// Restore original logger
debugLogger = oldLogger
// Check that output was generated
output := buf.String()
if !strings.Contains(output, "test message") {
t.Errorf("Debug output does not contain expected message. Got: %s", output)
}
}
})
}
}
//nolint:paralleltest // exercises process-global debug logger state
func TestDebugFunctions(t *testing.T) {
// Enable debug for testing
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
defer InitDebugLogging() // Re-initialize after test
InitDebugLogging()
if !IsDebugEnabled() {
t.Log("Debug not enabled, but continuing with debug function tests anyway")
}
// Test that debug functions don't panic and can be called
t.Run("Debug", func(_ *testing.T) {
Debug("test debug message")
Debug("test with args", "key", "value", "number", 42)
})
t.Run("Debugf", func(_ *testing.T) {
Debugf("formatted message: %s %d", "test", 123)
})
t.Run("DebugWith", func(_ *testing.T) {
DebugWith("structured message",
slog.String("string_key", "string_value"),
slog.Int("int_key", 42),
slog.Bool("bool_key", true),
)
})
}
+45
View File
@@ -0,0 +1,45 @@
package secret
import (
"fmt"
"os"
"path/filepath"
)
// DetermineStateDir determines the state directory based on environment
// variables and OS.
// It returns an error if no usable directory can be determined.
func DetermineStateDir(customConfigDir string) (string, error) {
// Check for environment variable first
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
return envStateDir, nil
}
// Use custom config dir if provided
if customConfigDir != "" {
return filepath.Join(customConfigDir, AppID), nil
}
// Use os.UserConfigDir() which handles platform-specific directories:
// - On Unix systems, it returns $XDG_CONFIG_HOME or $HOME/.config
// - On Darwin, it returns $HOME/Library/Application Support
// - On Windows, it returns %AppData%
configDir, err := os.UserConfigDir()
if err != nil {
// Fallback to a reasonable default if we can't determine user config dir
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return "", fmt.Errorf(
"unable to determine state directory: config dir: %w, home dir: %w",
err, homeErr)
}
fallbackDir := filepath.Join(homeDir, ".config", AppID)
Warn("Could not determine user config directory, falling back to default",
"fallback", fallbackDir, "error", err)
return fallbackDir, nil
}
return filepath.Join(configDir, AppID), nil
}
+59
View File
@@ -0,0 +1,59 @@
package secret_test
import (
"testing"
"sneak.berlin/go/secret/internal/secret"
)
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
// Clear all env vars that could provide a home/config directory.
// On Darwin, os.UserHomeDir may still succeed via the password
// database, so we also test via an explicit empty-customConfigDir
// path to exercise the fallback branch.
t.Setenv(secret.EnvStateDir, "")
t.Setenv("HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
result, err := secret.DetermineStateDir("")
// On systems where both lookups fail, we must get an error.
// On systems where the OS provides a fallback (e.g. macOS pw db),
// result should still be valid (non-empty, not root-relative).
if err != nil {
// Good — the error case is handled.
return
}
if result == "/.config/"+secret.AppID || result == "" {
t.Errorf(
"DetermineStateDir returned dangerous/empty path %q without error",
result)
}
}
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
t.Setenv(secret.EnvStateDir, "/custom/state")
result, err := secret.DetermineStateDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "/custom/state" {
t.Errorf("expected /custom/state, got %q", result)
}
}
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
t.Setenv(secret.EnvStateDir, "")
result, err := secret.DetermineStateDir("/my/config")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "/my/config/" + secret.AppID
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
}
+34
View File
@@ -0,0 +1,34 @@
package secret
import (
"time"
)
// VaultMetadata contains information about a vault
type VaultMetadata struct {
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
DerivationIndex uint32 `json:"derivationIndex"`
// Double SHA256 hash of the actual long-term public key
PublicKeyHash string `json:"publicKeyHash,omitempty"`
// Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
MnemonicFamilyHash string `json:"mnemonicFamilyHash,omitempty"`
}
// UnlockerMetadata contains information about an unlocker
type UnlockerMetadata struct {
Type string `json:"type"` // passphrase or pgp
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
}
// Metadata contains information about a secret
type Metadata struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Configuration represents the global configuration
type Configuration struct {
DefaultVault string `json:"defaultVault,omitempty"`
}
+265
View File
@@ -0,0 +1,265 @@
package secret_test
import (
"os"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// testMnemonic is the standard BIP39 test vector mnemonic.
//
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// writeTestPublicKey writes the unlocker public key and verifies it exists.
func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
) {
t.Helper()
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write public key: %v", err)
}
// Verify the file exists
exists, err := afero.Exists(fs, pubKeyPath)
if err != nil {
t.Fatalf("Failed to check if public key exists: %v", err)
}
if !exists {
t.Errorf("Public key file should exist at %s", pubKeyPath)
}
}
// writeTestPrivateKey encrypts the private key with the passphrase,
// writes it, and verifies it exists.
func writeTestPrivateKey(
t *testing.T,
fs afero.Fs,
unlockerDir string,
agePrivateKey string,
testPassphrase string,
) {
t.Helper()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
defer privKeyBuffer.Destroy()
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(
privKeyBuffer, passphraseBuffer)
if err != nil {
t.Fatalf("Failed to encrypt private key: %v", err)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age")
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Verify the file exists
exists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key exists: %v", err)
}
if !exists {
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
}
}
// writeTestLongTermKey encrypts the derived long-term key to the
// unlocker's recipient, writes it, and verifies it exists.
func writeTestLongTermKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
) {
t.Helper()
// Derive a long-term identity from the test mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term identity: %v", err)
}
// Encrypt long-term private key to the unlocker's recipient
recipient, err := age.ParseX25519Recipient(agePublicKey)
if err != nil {
t.Fatalf("Failed to parse recipient: %v", err)
}
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
if err != nil {
t.Fatalf("Failed to encrypt long-term private key: %v", err)
}
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
}
// Verify the file exists
exists, err := afero.Exists(fs, ltPrivKeyPath)
if err != nil {
t.Fatalf("Failed to check if long-term key exists: %v", err)
}
if !exists {
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
}
}
// newTestPassphraseUnlocker creates a temp unlocker directory and a
// passphrase unlocker with a fresh age identity for testing.
func newTestPassphraseUnlocker(
t *testing.T, fs afero.Fs,
) (*secret.PassphraseUnlocker, *age.X25519Identity, string) {
t.Helper()
// Create the directory structure in a temp dir
unlockerDir := filepath.Join(t.TempDir(), "unlocker")
err := os.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err)
}
// Set up test metadata
metadata := secret.UnlockerMetadata{
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Create passphrase unlocker
unlocker := secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
// Generate a test age identity
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
return unlocker, ageIdentity, unlockerDir
}
//nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem
if os.Getenv("CI") == "true" {
t.Log("Running in CI environment with real filesystem")
}
// Use the real filesystem
fs := afero.NewOsFs()
// Test data
testPassphrase := "test-passphrase-123"
unlocker, ageIdentity, unlockerDir := newTestPassphraseUnlocker(t, fs)
agePrivateKey := ageIdentity.String()
agePublicKey := ageIdentity.Recipient().String()
// Test writing public key
t.Run("WritePublicKey", func(t *testing.T) {
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
})
// Test encrypting private key with passphrase
t.Run("EncryptPrivateKey", func(t *testing.T) {
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
})
// Test writing long-term key
t.Run("WriteLongTermKey", func(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
})
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
unlocker.Passphrase = passphrase
// Test getting identity with the passphrase the unlocker was given,
// twice: using it must leave it intact for the next use
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
for range 2 {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
// Verify the identity matches what we expect
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
}
})
unlocker.Passphrase = nil
// Test getting identity from prompt (this would require mocking the
// prompt). For real integration tests, we'd need a way to mock the
// passphrase input. Here we just verify the error is what we expect
// when no passphrase is available.
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
// This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity()
if err == nil {
t.Errorf("Should have failed to get identity without a passphrase")
}
})
// Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) {
err := unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify the directory is gone
exists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if exists {
t.Errorf("Unlocker directory should not exist after removal")
}
})
}
-231
View File
@@ -1,231 +0,0 @@
package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"github.com/spf13/afero"
)
// PassphraseUnlockKey represents a passphrase-protected unlock key
type PassphraseUnlockKey struct {
Directory string
Metadata UnlockKeyMetadata
fs afero.Fs
}
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys
func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Read encrypted private key of unlock key
unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockKeyPrivPath)
if err != nil {
Debug("Failed to read passphrase unlock key private key", "error", err, "path", unlockKeyPrivPath)
return nil, fmt.Errorf("failed to read unlock key private key: %w", err)
}
DebugWith("Read encrypted passphrase unlock key",
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
// Get passphrase for decrypting the unlock key
var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
Debug("Using passphrase from environment variable", "key_id", p.GetID())
passphraseStr = envPassphrase
} else {
Debug("Prompting for passphrase", "key_id", p.GetID())
// Prompt for passphrase
passphraseStr, err = readPassphrase("Enter passphrase to unlock vault: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
}
Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
// Decrypt the unlock key private key with passphrase
privKeyData, err := decryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil {
Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)
}
DebugWith("Successfully decrypted unlock key private key",
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlock key identity", "key_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
if err != nil {
Debug("Failed to parse unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlock key private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements UnlockKey interface
func (p *PassphraseUnlockKey) GetType() string {
return "passphrase"
}
// GetMetadata implements UnlockKey interface
func (p *PassphraseUnlockKey) GetMetadata() UnlockKeyMetadata {
return p.Metadata
}
// GetDirectory implements UnlockKey interface
func (p *PassphraseUnlockKey) GetDirectory() string {
return p.Directory
}
// GetID implements UnlockKey interface
func (p *PassphraseUnlockKey) GetID() string {
return p.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from creation timestamp
func (p *PassphraseUnlockKey) ID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
}
// Remove implements UnlockKey interface - removes the passphrase unlock key
func (p *PassphraseUnlockKey) Remove() error {
// For passphrase keys, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove passphrase unlock key directory: %w", err)
}
return nil
}
// NewPassphraseUnlockKey creates a new PassphraseUnlockKey instance
func NewPassphraseUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PassphraseUnlockKey {
return &PassphraseUnlockKey{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreatePassphraseKey creates a new passphrase-protected unlock key
func CreatePassphraseKey(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlockKey, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseKey(passphrase)
}
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
func (p *PassphraseUnlockKey) DecryptLongTermKey() ([]byte, error) {
DebugWith("Decrypting long-term key with passphrase unlock key",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Get our unlock key identity
unlockIdentity, err := p.GetIdentity()
if err != nil {
Debug("Failed to get passphrase unlock identity for long-term decryption", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
}
// Read encrypted long-term private key
encryptedLtPrivKeyPath := filepath.Join(p.Directory, "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(p.fs, 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)
}
DebugWith("Read encrypted long-term private key",
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
)
// Decrypt long-term private key using our unlock key
Debug("Decrypting long-term private key with passphrase unlock key", "key_id", p.GetID())
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
DebugWith("Successfully decrypted long-term private key",
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(ltPrivKeyData)),
)
return ltPrivKeyData, nil
}
// DecryptSecret decrypts a secret using this passphrase unlock key's long-term key management
func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
DebugWith("Decrypting secret with passphrase unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Get encrypted secret data
encryptedData, err := secret.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted secret data for passphrase decryption", "error", err, "secret_name", secret.Name)
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
}
DebugWith("Retrieved encrypted secret data for passphrase decryption",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt long-term private key using our unlock key
ltPrivKeyData, err := p.DecryptLongTermKey()
if err != nil {
Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
// Use helper to parse long-term key and decrypt secret
decryptedData, err := decryptSecretWithLongTermKey(ltPrivKeyData, encryptedData)
if err != nil {
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID())
return nil, err
}
DebugWith("Successfully decrypted secret with passphrase unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
+187
View File
@@ -0,0 +1,187 @@
package secret
import (
"errors"
"fmt"
"log/slog"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"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")
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
// passphrase does not decrypt the unlocker's private key.
var ErrWrongPassphrase = errors.New("wrong passphrase")
// PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
}
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
func NewPassphraseUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *PassphraseUnlocker {
return &PassphraseUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for passphrase-based unlockers
func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
passphraseBuffer, err := p.getPassphrase()
if err != nil {
return nil, err
}
defer passphraseBuffer.Destroy()
// Read encrypted private key of unlocker
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlocker", "path", unlockerPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
if err != nil {
Debug("Failed to read passphrase unlocker private key",
"error", err, "path", unlockerPrivPath)
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
}
DebugWith("Read encrypted passphrase unlocker",
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt unlocker private key",
"error", err, "unlocker_id", p.GetID())
// age says that no identity matched when the passphrase is wrong
var noMatch *age.NoIdentityMatchError
if errors.As(err, &noMatch) {
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
}
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
}
defer privKeyBuffer.Destroy()
DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", privKeyBuffer.Size()),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements Unlocker interface
func (p *PassphraseUnlocker) GetType() string {
return "passphrase"
}
// GetMetadata implements Unlocker interface
func (p *PassphraseUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements Unlocker interface
func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PassphraseUnlocker) GetID() string {
return filepath.Base(p.Directory)
}
// Remove implements Unlocker interface - removes the passphrase unlocker
func (p *PassphraseUnlocker) Remove() error {
// Clean up the passphrase from memory if it exists
if p.Passphrase != nil && p.Passphrase.IsAlive() {
p.Passphrase.Destroy()
}
// For passphrase unlockers, we just need to remove the directory
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
}
return nil
}
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
// the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Not NewBufferFromBytes, which would wipe p.Passphrase
passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
}
Debug("No passphrase in memory, prompting user")
// Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, err
}
return secureBuffer, nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// The passphrase must be provided as a LockedBuffer for security
func CreatePassphraseUnlocker(
fs afero.Fs,
stateDir string,
passphrase *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseUnlocker(passphrase)
}
-1
View File
@@ -1 +0,0 @@
+588
View File
@@ -0,0 +1,588 @@
//go:build darwin
package secret_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"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.
const pgpUnlockerType = "pgp"
var errNilDataBuffer = errors.New("data buffer is nil")
// setupNonInteractiveGPG creates a custom GPG environment for testing
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
t.Helper()
// Create GPG config file for non-interactive operation
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
gpgConfContent := `batch
no-tty
pinentry-mode loopback
`
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
if err != nil {
t.Fatalf("Failed to write GPG config file: %v", err)
}
// Create a test-specific GPG implementation
origEncryptFunc := secret.GPGEncryptFunc
origDecryptFunc := secret.GPGDecryptFunc
// Set custom GPG functions for this test
secret.GPGEncryptFunc = func(
data *memguard.LockedBuffer, keyID string,
) ([]byte, error) {
if data == nil {
return nil, errNilDataBuffer
}
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir,
"--batch",
"--yes",
"--pinentry-mode", "loopback",
"--passphrase", passphrase,
"--trust-model", "always",
"--armor",
"--encrypt",
"-r", keyID)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(data.Bytes())
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
}
return stdout.Bytes(), nil
}
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir,
"--batch",
"--yes",
"--pinentry-mode", "loopback",
"--passphrase", passphrase,
"--quiet",
"--decrypt")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(encryptedData)
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
}
// Create a secure buffer for the decrypted data
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
}
// Restore original functions after test
t.Cleanup(func() {
secret.GPGEncryptFunc = origEncryptFunc
secret.GPGDecryptFunc = origDecryptFunc
})
}
// runGPGWithPassphrase executes a GPG command with the specified passphrase
func runGPGWithPassphrase(
ctx context.Context,
gnupgHome, passphrase string, args []string, input io.Reader,
) ([]byte, error) {
cmdArgs := append([]string{
"--homedir=" + gnupgHome,
"--batch",
"--yes",
"--pinentry-mode", "loopback",
"--passphrase", passphrase,
}, args...)
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
cmd.Stdin = input
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG command failed: %w\nStderr: %s", err, stderr.String())
}
return stdout.Bytes(), nil
}
// generateTestGPGKey generates a GPG key protected by passphrase in
// gnupgHomeDir and returns its key ID and fingerprint.
func generateTestGPGKey(
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
) (string, string) {
t.Helper()
// Create GPG batch file for key generation
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + passphrase + `
%commit
%echo Key generation completed
`
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
if err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
if err != nil {
t.Fatalf("Failed to list GPG keys: %v", err)
}
// Parse output to get key ID and fingerprint
var keyID, fingerprint string
for line := range strings.SplitSeq(string(output), "\n") {
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
if len(fields) >= 5 {
keyID = fields[4]
}
} else if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
fingerprint = fields[9]
break
}
}
}
if keyID == "" {
t.Fatalf("Failed to find GPG key ID in output: %s", output)
}
if fingerprint == "" {
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
}
t.Logf("Generated GPG key ID: %s", keyID)
t.Logf("Generated GPG fingerprint: %s", fingerprint)
return keyID, fingerprint
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestPGPUnlockerWithRealFS(t *testing.T) {
// Check if gpg is available
_, err := exec.LookPath("gpg")
if err != nil {
t.Log("GPG not available, PGP unlock key tests may not fully function")
// Continue anyway to test what we can
}
// Create a temporary directory for our tests. Not t.TempDir: its longer
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
// macOS sets on socket paths.
//
//nolint:usetesting // see the comment above
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
// Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
err = os.MkdirAll(gnupgHomeDir, 0o700)
if err != nil {
t.Fatalf("Failed to create GNUPGHOME: %v", err)
}
// Set new GNUPGHOME
t.Setenv("GNUPGHOME", gnupgHomeDir)
// Test passphrase for GPG key
testPassphrase := "test123"
// Setup non-interactive GPG with custom functions
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
t.Setenv("GPG_AGENT_INFO", "")
// Use the real filesystem
fs := afero.NewOsFs()
// Set test environment variables
t.Setenv(secret.EnvGPGKeyID, keyID)
// Set up vault structure for testing
stateDir := tempDir
vaultName := "test-vault"
// Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlocker", func(t *testing.T) {
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
})
// Set up key directory for individual tests
unlockerDir := filepath.Join(tempDir, "unlocker")
err = os.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err)
}
// Set up test metadata
metadata := secret.UnlockerMetadata{
Type: pgpUnlockerType,
CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"},
}
// Create a PGP unlocker for the remaining tests
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) {
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
})
// Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) {
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
})
}
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
// and metadata.
func testCreatePGPUnlocker(
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
) {
t.Helper()
// Set a limited test timeout to avoid hanging
timer := time.AfterFunc(10*time.Second, func() {
t.Fatalf("Test timed out after 10 seconds")
})
defer timer.Stop()
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(
fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != pgpUnlockerType {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check that the ID is the name of the unlocker's directory
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
}
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
}
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
// its files.
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
t.Helper()
// Check if the key directory exists
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
}
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
// unlockerDir names its type and the GPG key by fingerprint.
func checkPGPUnlockerMetadata(
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
) {
t.Helper()
// Read and verify metadata
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != pgpUnlockerType {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir, keyID string,
) {
t.Helper()
// Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath,
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
func testRemovePGPUnlocker(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
) {
t.Helper()
// Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}
+460
View File
@@ -0,0 +1,460 @@
package secret
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"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 (
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
errNilDataBuffer = errors.New("data buffer is nil")
)
// 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
var (
// GPGEncryptFunc is the function used for GPG encryption
// Can be overridden in tests to provide a non-interactive implementation
//nolint:gochecknoglobals // Required for test mocking
GPGEncryptFunc func(
data *memguard.LockedBuffer, keyID string,
) ([]byte, error) = gpgEncryptDefault
// GPGDecryptFunc is the function used for GPG decryption
// Can be overridden in tests to provide a non-interactive implementation
//nolint:gochecknoglobals // Required for test mocking
GPGDecryptFunc func(
encryptedData []byte,
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
// gpgKeyIDRegex validates GPG key IDs
// Allows either:
// 1. Email addresses (user@domain.tld format)
// 2. Short key IDs (8 hex characters)
// 3. Long key IDs (16 hex characters)
// 4. Full fingerprints (40 hex characters)
gpgKeyIDRegex = regexp.MustCompile(
`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
`^[A-Fa-f0-9]{8}$|` +
`^[A-Fa-f0-9]{16}$|` +
`^[A-Fa-f0-9]{40}$`,
)
)
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
type PGPUnlockerMetadata struct {
UnlockerMetadata
// GPG key ID used for encryption
GPGKeyID string `json:"gpgKeyId"`
}
// PGPUnlocker represents a PGP-protected unlocker
type PGPUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewPGPUnlocker creates a new PGPUnlocker instance
func NewPGPUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *PGPUnlocker {
return &PGPUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for PGP-based unlockers
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting PGP unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
// Step 1: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg")
Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath)
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
if err != nil {
Debug("Failed to read PGP-encrypted age private key",
"error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
}
DebugWith("Read PGP-encrypted age private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 2: Decrypt the age private key using GPG
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
if err != nil {
Debug("Failed to decrypt age private key with GPG",
"error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
}
defer agePrivKeyBuffer.Destroy()
DebugWith("Successfully decrypted age private key with GPG",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
)
// Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed PGP age identity",
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements Unlocker interface
func (p *PGPUnlocker) GetType() string {
return "pgp"
}
// GetMetadata implements Unlocker interface
func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements Unlocker interface
func (p *PGPUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PGPUnlocker) GetID() string {
return filepath.Base(p.Directory)
}
// Remove implements Unlocker interface - removes the PGP unlocker
func (p *PGPUnlocker) Remove() error {
// For PGP unlockers, we just need to remove the directory
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
}
return nil
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and time
func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
}
// pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the time.
//
//nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir(
fs afero.Fs, stateDir string,
) (VaultInterface, string, error) {
// Get current vault
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the unlocker name based on hostname and time
unlockerName, err := generatePGPUnlockerName()
if err != nil {
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
}
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
}
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
}
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
// Everything that can fail short of writing a file is done before anything
// is written, and the files are written through WriteDir, so a failure
// leaves no partial unlocker. The long-term key comes from mnemonic when it
// is not nil, else from the current unlocker, which is given passphrase when
// it is a passphrase unlocker.
func CreatePGPUnlocker(
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) {
err := checkGPGAvailable()
if err != nil {
return nil, err
}
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
if err != nil {
return nil, err
}
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
vault.SetMnemonic(mnemonic)
vault.SetUnlockPassphrase(passphrase)
// Step 1: Generate a new age keypair for the PGP unlocker
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
}
// Step 2: Encrypt the long-term private key to the new keypair, and the
// keypair's private key to the GPG key
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
vault, ageIdentity, gpgKeyID)
if err != nil {
return nil, err
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: "pgp",
CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"},
},
GPGKeyID: fingerprint,
}
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
// Step 3: Write the unlocker's files, the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error {
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
})
if err != nil {
return nil, err
}
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
// to the new PGP unlocker's age keypair, and that keypair's private key
// encrypted to the GPG key gpgKeyID.
func encryptPGPUnlockerKeys(
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
) ([]byte, []byte, error) {
// From the mnemonic or the current unlocker, as for a passphrase unlocker
ltIdentity, err := vault.GetOrDeriveLongTermKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
}
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
}
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
if err != nil {
return nil, nil, fmt.Errorf(
"failed to encrypt age private key with GPG: %w", err)
}
return encryptedLtPrivKey, encryptedAgePrivKey, nil
}
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
// metadata last.
func writePGPUnlockerFiles(
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
[]byte(ageRecipient.String()))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil
}
// validateGPGKeyID validates that a GPG key ID is safe for command execution
func validateGPGKeyID(keyID string) error {
if keyID == "" {
return errGPGKeyIDEmpty
}
if !gpgKeyIDRegex.MatchString(keyID) {
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
}
return nil
}
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
err := validateGPGKeyID(keyID)
if err != nil {
return "", fmt.Errorf("invalid GPG key ID: %w", err)
}
// 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
context.Background(),
"gpg", "--status-fd", "1",
"--list-keys", "--with-colons", "--fingerprint", keyID,
)
output, err := cmd.Output()
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)
}
// Parse the output to extract the fingerprint
for line := range strings.SplitSeq(string(output), "\n") {
if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
return fields[9], nil
}
}
}
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
}
// checkGPGAvailable verifies that GPG is available
func checkGPGAvailable() error {
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
err := cmd.Run()
if err != nil {
return fmt.Errorf(
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
err)
}
return nil
}
// gpgEncryptDefault is the default implementation of GPG encryption
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
if data == nil {
return nil, errNilDataBuffer
}
err := validateGPGKeyID(keyID)
if err != nil {
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
}
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
context.Background(),
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
)
cmd.Stdin = strings.NewReader(data.String())
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w", err)
}
return output, nil
}
// gpgDecryptDefault is the default implementation of GPG decryption
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
cmd.Stdin = strings.NewReader(string(encryptedData))
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w", err)
}
// Create a secure buffer for the decrypted data
outputBuffer := memguard.NewBufferFromBytes(output)
return outputBuffer, nil
}
+104
View File
@@ -0,0 +1,104 @@
package secret_test
import (
"os"
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"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"
)
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
const (
testGPGKeyID = "0123456789ABCDEF"
testGPGFingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"
)
// fakeGPGScript is a gpg for which `gpg --version` succeeds and anything
// else fails.
const fakeGPGScript = `#!/bin/sh
[ "$*" = --version ]
`
// installFakeGPG makes fakeGPGScript the only gpg on PATH for the test.
func installFakeGPG(t *testing.T) {
t.Helper()
dir := t.TempDir()
//nolint:gosec // G306: the script must be executable
err := os.WriteFile(filepath.Join(dir, "gpg"), []byte(fakeGPGScript), 0o700)
require.NoError(t, err)
t.Setenv("PATH", dir)
}
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
// getting the vault's long-term key, which used to come after part of the
// unlocker was written, and asserts that nothing is written. Getting the key
// fails because there is no mnemonic and no current unlocker.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t)
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
t.Errorf("changed %s", path)
return nil
}}
_, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
require.Error(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
}
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
firstFiles := dirFiles(t, fs, first.GetDirectory())
second, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
}
+170
View File
@@ -0,0 +1,170 @@
package secret
import (
"errors"
"log/slog"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
var (
errGetEncryptedDataDeprecated = errors.New(
"GetEncryptedData is deprecated - use version-specific methods")
errGetCurrentVaultNotRegistered = errors.New(
"GetCurrentVault function not registered")
)
// VaultInterface defines the interface that vault implementations must satisfy
type VaultInterface interface {
GetDirectory() (string, error)
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
GetName() string
GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error)
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
// mnemonic to derive the long-term key from, and the passphrase for a
// current passphrase unlocker; nil for none.
SetMnemonic(mnemonic *memguard.LockedBuffer)
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
}
// Secret represents a secret in a vault
type Secret struct {
Name string
Directory string
Metadata Metadata
vault VaultInterface
}
// NewSecret creates a new Secret instance
func NewSecret(vault VaultInterface, name string) *Secret {
DebugWith("Creating new secret instance",
slog.String("secret_name", name),
slog.String("vault_name", vault.GetName()),
)
// Convert slashes to percent signs for storage directory name
storageName := strings.ReplaceAll(name, "/", "%")
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
DebugWith("Secret storage details",
slog.String("secret_name", name),
slog.String("storage_name", storageName),
slog.String("secret_dir", secretDir),
)
return &Secret{
Name: name,
Directory: secretDir,
vault: vault,
Metadata: Metadata{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
}
// LoadMetadata is deprecated - metadata is now per-version and encrypted
func (s *Secret) LoadMetadata() error {
Debug("LoadMetadata called but is deprecated in versioned model",
"secret_name", s.Name)
// For backward compatibility, we'll populate with basic info
now := time.Now()
s.Metadata = Metadata{
CreatedAt: now,
UpdatedAt: now,
}
return nil
}
// GetMetadata returns the secret metadata (deprecated)
func (s *Secret) GetMetadata() Metadata {
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
return s.Metadata
}
// GetEncryptedData is deprecated - data is now stored in versions
func (s *Secret) GetEncryptedData() ([]byte, error) {
Debug("GetEncryptedData called but is deprecated in versioned model",
"secret_name", s.Name)
return nil, errGetEncryptedDataDeprecated
}
// Exists checks if the secret exists on disk
func (s *Secret) Exists() (bool, error) {
DebugWith("Checking if secret exists",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// Check if the secret directory exists and has a current symlink
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to check secret directory existence",
"error", err, "secret_dir", s.Directory)
return false, err
}
if !exists {
Debug("Secret directory does not exist", "secret_dir", s.Directory)
return false, nil
}
// Check if current symlink exists
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("No current version found", "error", err, "secret_name", s.Name)
return false, nil
}
DebugWith("Secret existence check result",
slog.String("secret_name", s.Name),
slog.Bool("exists", true),
)
return true, nil
}
// GetCurrentVault gets the current vault from the file system
// This function is a wrapper around the actual implementation in the vault package
// and exists to break the import cycle.
//
//nolint:ireturn // must return the interface to break the import cycle
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
// This is a forward declaration. The actual implementation is provided
// by the vault package when it calls RegisterGetCurrentVaultFunc.
if getCurrentVaultFunc == nil {
return nil, errGetCurrentVaultNotRegistered
}
return getCurrentVaultFunc(fs, stateDir)
}
// getCurrentVaultFunc is a function variable that will be set by the vault package
// to implement the actual GetCurrentVault functionality
//
//nolint:gochecknoglobals // Required to break import cycle
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
// RegisterGetCurrentVaultFunc allows the vault package to register its
// implementation of GetCurrentVault to break the import cycle
func RegisterGetCurrentVaultFunc(
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
) {
getCurrentVaultFunc = fn
}
+329
View File
@@ -0,0 +1,329 @@
//nolint:testpackage // white-box test of unexported internals
package secret
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/pkg/agehd"
)
// testMnemonicValue is the standard BIP39 test vector mnemonic.
//
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
var (
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock")
)
// 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
type MockVault struct {
name string
fs afero.Fs
directory string
derivationIndex uint32
mnemonic *memguard.LockedBuffer
}
func (m *MockVault) GetDirectory() (string, error) {
return m.directory, nil
}
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
// Create secret directory with proper storage name conversion
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
err := m.fs.MkdirAll(secretDir, 0o700)
if err != nil {
return err
}
// Create version directory with proper path
versionName := "20240101.001" // Use a fixed version name for testing
versionDir := filepath.Join(secretDir, "versions", versionName)
err = m.fs.MkdirAll(versionDir, 0o700)
if err != nil {
return err
}
// Read the vault's long-term public key
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index
if m.mnemonic == nil {
return errMnemonicNotSet
}
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil {
return err
}
// Write long-term public key if it doesn't exist
_, err = m.fs.Stat(ltPubKeyPath)
if os.IsNotExist(err) {
pubKey := ltIdentity.Recipient().String()
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
if err != nil {
return err
}
}
err = m.writeVersionFiles(versionDir, value, ltIdentity)
if err != nil {
return err
}
// Create current file pointing to the version (just the version name)
currentLink := filepath.Join(secretDir, "current")
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
}
func (m *MockVault) GetName() string {
return m.name
}
//nolint:ireturn // implements VaultInterface
func (m *MockVault) GetFilesystem() afero.Fs {
return m.fs
}
//nolint:ireturn // implements VaultInterface
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
m.mnemonic = mnemonic
}
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
return nil, errNotImplementedInMock
}
// writeVersionFiles generates a version keypair and writes the version
// key and value files for the mock vault.
func (m *MockVault) writeVersionFiles(
versionDir string,
value *memguard.LockedBuffer,
ltIdentity *age.X25519Identity,
) error {
// Generate version-specific keypair
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
return err
}
// Write version public key
pubKeyPath := filepath.Join(versionDir, "pub.age")
err = afero.WriteFile(
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
if err != nil {
return err
}
// Encrypt value to version's public key (value is already a LockedBuffer)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
return err
}
// Write encrypted value
valuePath := filepath.Join(versionDir, "value.age")
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
if err != nil {
return err
}
// Encrypt version private key to long-term public key
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
defer versionPrivKeyBuffer.Destroy()
encryptedPrivKey, err := EncryptToRecipient(
versionPrivKeyBuffer, ltIdentity.Recipient())
if err != nil {
return err
}
// Write encrypted version private key
privKeyPath := filepath.Join(versionDir, "priv.age")
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
}
// setupMockVaultDirs creates the vault directory structure, long-term
// public key, and current vault pointer for tests.
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
t.Helper()
// Create vault directory structure
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Generate a long-term keypair for the vault using the test mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonicValue, 0)
if err != nil {
t.Fatalf("Failed to generate long-term identity: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs,
ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()),
0o600,
)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Set current vault
currentVaultPath := filepath.Join(baseDir, "currentvault")
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
if err != nil {
t.Fatalf("Failed to set current vault: %v", err)
}
}
// verifySecretFiles checks that AddSecret created the expected version
// files for the secret.
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
t.Helper()
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
// Check versions directory exists
versionsDir := filepath.Join(secretDir, "versions")
versionsDirExists, err := afero.DirExists(fs, versionsDir)
if err != nil || !versionsDirExists {
t.Fatalf("versions directory was not created")
}
// Check current file exists and points at a version
currentVersion, err := GetCurrentVersion(fs, secretDir)
if err != nil {
t.Fatalf("Failed to get current version: %v", err)
}
// Check value.age exists in the version directory
versionDir := filepath.Join(versionsDir, currentVersion)
valueExists, err := afero.Exists(fs, filepath.Join(versionDir, "value.age"))
if err != nil || !valueExists {
t.Fatalf("value.age file was not created in version directory")
}
}
//nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
setupMockVaultDirs(t, fs, baseDir, vaultDir)
// Create vault instance using the mock vault
vault := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 0,
mnemonic: mnemonic,
}
// Test data
secretName := "test-secret"
secretValue := []byte("this is a test secret value")
// Create a secure buffer for the test value
valueBuffer := memguard.NewBufferFromBytes(secretValue)
defer valueBuffer.Destroy()
// Test AddSecret
t.Run("AddSecret", func(t *testing.T) {
err := vault.AddSecret(secretName, valueBuffer, false)
if err != nil {
t.Fatalf("AddSecret failed: %v", err)
}
// Verify that all expected files were created
verifySecretFiles(t, fs, vaultDir, secretName)
t.Logf("All expected files created successfully with versioning")
})
// Create a Secret object to test with
secret := NewSecret(vault, secretName)
// Test GetValue (this will need to be modified since we're using a mock vault)
t.Run("GetSecret", func(t *testing.T) {
// This test is simplified since we're not implementing the full encryption/decryption
// in the mock. We just verify the Secret object is created correctly.
if secret.Name != secretName {
t.Fatalf("Secret name doesn't match. Expected: %s, Got: %s", secretName, secret.Name)
}
if secret.vault != vault {
t.Fatalf("Secret vault reference doesn't match expected vault")
}
t.Logf("Successfully created Secret object with correct properties")
})
// Test Exists
t.Run("SecretExists", func(t *testing.T) {
exists, err := secret.Exists()
if err != nil {
t.Fatalf("Error checking if secret exists: %v", err)
}
if !exists {
t.Fatalf("Secret should exist but Exists() returned false")
}
t.Logf("Secret.Exists() works correctly")
})
}
-22
View File
@@ -1,22 +0,0 @@
package secret
import (
"filippo.io/age"
)
// UnlockKey interface defines the methods all unlock key types must implement
type UnlockKey interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockKeyMetadata
GetDirectory() string
GetID() string
ID() string // Generate ID from the key's public key
Remove() error // Remove the unlock key and any associated resources
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
DecryptLongTermKey() ([]byte, error)
// DecryptSecret decrypts a secret using this unlock key's long-term key management
DecryptSecret(secret *Secret) ([]byte, error)
}
+15
View File
@@ -0,0 +1,15 @@
package secret
import (
"filippo.io/age"
)
// Unlocker interface defines the methods all unlocker types must implement
type Unlocker interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockerMetadata
GetDirectory() string
GetID() string // The name of the unlocker's directory, unique in its vault
Remove() error // Remove the unlocker and any associated resources
}
+69
View File
@@ -0,0 +1,69 @@
//nolint:testpackage // white-box test of unexported internals
package secret
import (
"testing"
)
func TestValidateGPGKeyID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
keyID string
wantErr bool
}{
// Valid cases
{"valid email address", "test@example.com", false},
{"valid email with dots and hyphens", "test.user-name@example-domain.co.uk", false},
{"valid email with plus", "test+tag@example.com", false},
{"valid short key ID (8 hex chars)", "ABCDEF12", false},
{"valid long key ID (16 hex chars)", "ABCDEF1234567890", false},
{
"valid fingerprint (40 hex chars)",
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
},
{
"valid lowercase hex fingerprint",
"abcdef1234567890abcdef1234567890abcdef12", false,
},
{"valid mixed case hex", "AbCdEf1234567890", false},
// Invalid cases
{"empty key ID", "", true},
{"key ID with spaces", "test user@example.com", true},
{"key ID with semicolon (command injection)", "test@example.com; rm -rf /", true},
{
"key ID with pipe (command injection)",
"test@example.com | cat /etc/passwd", true,
},
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
{
"key ID with dollar sign (command injection)",
"test@example.com$(whoami)", true,
},
{"key ID with quotes", "test\"@example.com", true},
{"key ID with single quotes", "test'@example.com", true},
{"key ID with backslash", "test\\@example.com", true},
{"key ID with newline", "test@example.com\nrm -rf /", true},
{"key ID with carriage return", "test@example.com\rrm -rf /", true},
{"hex with invalid length (7 chars)", "ABCDEF1", true},
{"hex with invalid length (9 chars)", "ABCDEF123", true},
{"hex with non-hex characters", "ABCDEFGH", true},
{"mixed format (email with hex)", "test@ABCDEF12", true},
{"key ID with ampersand", "test@example.com & echo test", true},
{"key ID with redirect", "test@example.com > /tmp/test", true},
{"key ID with null byte", "test@example.com\x00", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateGPGKeyID(tt.keyID)
if (err != nil) != tt.wantErr {
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
-1
View File
@@ -1 +0,0 @@
+591
View File
@@ -0,0 +1,591 @@
package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/oklog/ulid/v2"
"github.com/spf13/afero"
)
const (
versionNameParts = 2
maxVersionsPerDay = 999
)
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
// 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
type VersionMetadata struct {
ID string `json:"id"` // ULID
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
}
// Version represents a version of a secret
type Version struct {
SecretName string
Version string
Directory string
Metadata VersionMetadata
vault VaultInterface
}
// NewVersion creates a new Version instance
func NewVersion(vault VaultInterface, secretName string, version string) *Version {
DebugWith("Creating new secret version instance",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("vault_name", vault.GetName()),
)
vaultDir, _ := vault.GetDirectory()
storageName := strings.ReplaceAll(secretName, "/", "%")
versionDir := filepath.Join(vaultDir, "secrets.d", storageName, "versions", version)
DebugWith("Secret version storage details",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("version_dir", versionDir),
)
now := time.Now()
return &Version{
SecretName: secretName,
Version: version,
Directory: versionDir,
vault: vault,
Metadata: VersionMetadata{
ID: ulid.Make().String(),
CreatedAt: &now,
},
}
}
// GenerateVersionName generates a new version name in YYYYMMDD.NNN format
func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
today := time.Now().Format("20060102")
versionsDir := filepath.Join(secretDir, "versions")
// Ensure versions directory exists
err := fs.MkdirAll(versionsDir, DirPerms)
if err != nil {
return "", fmt.Errorf("failed to create versions directory: %w", err)
}
// Find the highest serial number for today
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return "", fmt.Errorf("failed to read versions directory: %w", err)
}
maxSerial := 0
prefix := today + "."
for _, entry := range entries {
// Skip non-directories and those without correct prefix
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
continue
}
// Extract serial number
parts := strings.Split(entry.Name(), ".")
if len(parts) != versionNameParts {
continue
}
var serial int
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
if err != nil {
Warn("Skipping malformed version directory name",
"name", entry.Name(), "error", err)
continue
}
if serial > maxSerial {
maxSerial = serial
}
}
// Generate new version name
newSerial := maxSerial + 1
if newSerial > maxVersionsPerDay {
return "", errMaxVersionsPerDay
}
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value. The files are written into a
// temporary directory that is renamed to sv.Directory once all of them are
// complete, so the version directory is either whole or absent, even if the
// process dies part-way.
func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil {
return ErrNilValueBuffer
}
DebugWith("Saving secret version",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
slog.Int("value_length", value.Size()),
)
fs := sv.vault.GetFilesystem()
// Create the versions directory the finished version is renamed into
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
if err != nil {
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create versions directory: %w", err)
}
tmpDir, err := TempDirFor(fs, sv.Directory)
if err != nil {
return err
}
// Once the rename below has moved it into place, this finds nothing.
defer func() { _ = fs.RemoveAll(tmpDir) }()
// Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
return fmt.Errorf("failed to generate version keypair: %w", err)
}
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair",
slog.String("version", sv.Version),
slog.String("public_key", versionIdentity.Recipient().String()),
)
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
if err != nil {
return err
}
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
if err != nil {
return err
}
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
if err != nil {
return err
}
err = fs.Rename(tmpDir, sv.Directory)
if err != nil {
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to move version into place: %w", err)
}
Debug("Successfully saved secret version",
"version", sv.Version, "secret_name", sv.SecretName)
return nil
}
// LoadMetadata loads and decrypts the version metadata
func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
DebugWith("Loading version metadata",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key",
"error", err, "path", encryptedPrivKeyPath)
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Step 2: Decrypt version private key using long-term key
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted metadata
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
if err != nil {
Debug("Failed to read encrypted version metadata",
"error", err, "path", encryptedMetadataPath)
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
}
// Step 5: Decrypt metadata using version key
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
if err != nil {
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version metadata: %w", err)
}
defer metadataBuffer.Destroy()
// Step 6: Unmarshal metadata
var metadata VersionMetadata
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
if err != nil {
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
}
sv.Metadata = metadata
Debug("Successfully loaded version metadata", "version", sv.Version)
return nil
}
// GetValue retrieves and decrypts the version value
func (sv *Version) GetValue(
ltIdentity *age.X25519Identity,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting version value",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
// Debug: Log the directory and long-term key info
Debug("SecretVersion GetValue debug info",
"secret_name", sv.SecretName,
"version", sv.Version,
"directory", sv.Directory,
"lt_identity_public_key", ltIdentity.Recipient().String())
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key",
"error", err, "path", encryptedPrivKeyPath)
return nil, fmt.Errorf(
"failed to read encrypted version private key: %w", err)
}
Debug("Successfully read encrypted version private key",
"path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
// Step 2: Decrypt version private key using long-term key
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
Debug("Successfully decrypted version private key",
"version", sv.Version, "size", versionPrivKeyBuffer.Size())
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted value
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
Debug("Reading encrypted value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted version value",
"error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
}
Debug("Successfully read encrypted value",
"path", encryptedValuePath, "size", len(encryptedValue))
// Step 5: Decrypt value using version key
Debug("Decrypting value with version identity", "version", sv.Version)
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
}
Debug("Successfully retrieved version value",
"version", sv.Version,
"value_length", valueBuffer.Size(),
"is_empty", valueBuffer.Size() == 0)
return valueBuffer, nil
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it in dir.
func (sv *Version) writePublicKeyAndValue(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
value *memguard.LockedBuffer,
) error {
versionPublicKey := versionIdentity.Recipient().String()
pubKeyPath := filepath.Join(dir, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// Encrypt the value to the version's public key
Debug("Encrypting value to version's public key", "version", sv.Version)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version value: %w", err)
}
valuePath := filepath.Join(dir, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = WriteFileAtomic(fs, valuePath, encryptedValue)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
return nil
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it in dir.
func (sv *Version) writeEncryptedPrivateKey(
fs afero.Fs,
dir string,
versionPrivateKeyBuffer *memguard.LockedBuffer,
) error {
vaultDir, _ := sv.vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
if err != nil {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
}
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
if err != nil {
Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
}
Debug("Encrypting version private key to long-term public key",
"version", sv.Version)
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
if err != nil {
Debug("Failed to encrypt version private key",
"error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
privKeyPath := filepath.Join(dir, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil {
Debug("Failed to write encrypted version private key",
"error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted version private key: %w", err)
}
return nil
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it in dir.
func (sv *Version) writeEncryptedMetadata(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
) error {
Debug("Encrypting version metadata", "version", sv.Version)
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
if err != nil {
Debug("Failed to marshal version metadata", "error", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := EncryptToRecipient(
metadataBuffer, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(dir, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
return nil
}
// ListVersions lists all versions of a secret
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
versionsDir := filepath.Join(secretDir, "versions")
// Check if versions directory exists
exists, err := afero.DirExists(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to check versions directory: %w", err)
}
if !exists {
return []string{}, nil
}
// List all version directories
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to read versions directory: %w", err)
}
var versions []string
for _, entry := range entries {
if entry.IsDir() {
versions = append(versions, entry.Name())
}
}
// Sort versions in reverse chronological order
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
return versions, nil
}
// VersionExists reports whether version is one of the versions ListVersions
// lists for the secret in secretDir. It only compares names, so a version
// the user typed can be checked with it before any path is built from it.
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
versions, err := ListVersions(fs, secretDir)
if err != nil {
return false, err
}
return slices.Contains(versions, version), nil
}
// GetCurrentVersion returns the version that the "current" file points to
// The file contains just the version name (e.g., "20231215.001"). 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) {
currentPath := filepath.Join(secretDir, "current")
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
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))
return version, nil
}
// SetCurrentVersion updates the "current" file to point to a specific version
// The file contains just the version name (e.g., "20231215.001"). It is
// replaced in one rename, so once written it always exists.
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
err := WriteFileAtomic(fs, currentPath, []byte(version))
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}
return nil
}
+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)
}
+408
View File
@@ -0,0 +1,408 @@
// Version Support Test Suite Documentation
//
// This file contains core unit tests for version functionality:
//
// - TestGenerateVersionName: Tests version name generation with date and serial format
// - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit
// - TestNewVersion: Tests secret version object creation
// - TestSecretVersionSave: Tests saving a version with encryption
// - TestSecretVersionLoadMetadata: Tests loading and decrypting version metadata
// - TestSecretVersionGetValue: Tests retrieving and decrypting version values
// - TestListVersions: Tests listing versions in reverse chronological order
// - TestGetCurrentVersion: Tests retrieving the current version via symlink
// - TestSetCurrentVersion: Tests updating the current version symlink
// - TestVersionMetadataTimestamps: Tests timestamp pointer consistency
//
// Key Test Scenarios:
// - Version Creation: First version gets notBefore = epoch + 1 second
// - Subsequent versions update previous version's notAfter timestamp
// - New version's notBefore equals previous version's notAfter
// - Version names follow YYYYMMDD.NNN format
// - Maximum 999 versions per day enforced
//
// Version Retrieval:
// - Get current version via symlink
// - Get specific version by name
// - Empty version parameter returns current
// - Non-existent versions return appropriate errors
//
// Data Integrity:
// - Each version has independent encryption keys
// - Metadata encryption protects version history
// - Long-term key required for all operations
// - Concurrent reads handled safely
package secret_test
import (
"errors"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
const (
testSecretDir = "/test/secret"
testVaultName = "test"
testVaultStateDir = "/test"
)
var errNotImplementedInMock = errors.New("not implemented in mock")
// MockVersionVault implements VaultInterface for testing
type MockVersionVault struct {
Name string
fs afero.Fs
stateDir string
longTermKey *age.X25519Identity
}
func (m *MockVersionVault) GetDirectory() (string, error) {
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
}
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
return errNotImplementedInMock
}
func (m *MockVersionVault) GetName() string {
return m.Name
}
//nolint:ireturn // implements VaultInterface
func (m *MockVersionVault) GetFilesystem() afero.Fs {
return m.fs
}
//nolint:ireturn // implements VaultInterface
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {
return nil, errNotImplementedInMock
}
func TestGenerateVersionName(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
// Test first version generation
version1, err := secret.GenerateVersionName(fs, secretDir)
require.NoError(t, err)
assert.Regexp(t, `^\d{8}\.001$`, version1)
// Create the version directory
versionDir := filepath.Join(secretDir, "versions", version1)
err = fs.MkdirAll(versionDir, 0o755)
require.NoError(t, err)
// Test second version generation on same day
version2, err := secret.GenerateVersionName(fs, secretDir)
require.NoError(t, err)
assert.Regexp(t, `^\d{8}\.002$`, version2)
// Verify they have the same date prefix
assert.Equal(t, version1[:8], version2[:8])
assert.NotEqual(t, version1, version2)
}
func TestNewVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: testVaultName,
fs: fs,
stateDir: testVaultStateDir,
}
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
assert.Equal(t, "test/secret", sv.SecretName)
assert.Equal(t, "20231215.001", sv.Version)
assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001")
assert.NotEmpty(t, sv.Metadata.ID)
assert.NotNil(t, sv.Metadata.CreatedAt)
}
func TestSecretVersionSave(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: testVaultName,
fs: fs,
stateDir: testVaultStateDir,
}
// Create vault directory structure and long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0o755)
require.NoError(t, err)
// Generate and store long-term public key
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Create and save a version
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
testValue := []byte("test-secret-value")
testBuffer := memguard.NewBufferFromBytes(testValue)
defer testBuffer.Destroy()
err = sv.Save(testBuffer)
require.NoError(t, err)
// Verify files were created
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age")))
}
func TestSecretVersionLoadMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: testVaultName,
fs: fs,
stateDir: testVaultStateDir,
}
// Setup vault with long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0o755)
require.NoError(t, err)
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Create and save a version with custom metadata
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
now := time.Now()
epochPlusOne := time.Unix(1, 0)
sv.Metadata.NotBefore = &epochPlusOne
sv.Metadata.NotAfter = &now
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
defer testBuffer.Destroy()
err = sv.Save(testBuffer)
require.NoError(t, err)
// Create new version object and load metadata
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
err = sv2.LoadMetadata(ltIdentity)
require.NoError(t, err)
// Verify loaded metadata
assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID)
assert.NotNil(t, sv2.Metadata.NotBefore)
assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix())
assert.NotNil(t, sv2.Metadata.NotAfter)
}
func TestSecretVersionGetValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: testVaultName,
fs: fs,
stateDir: testVaultStateDir,
}
// Setup vault with long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0o755)
require.NoError(t, err)
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Create and save a version
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
originalValue := []byte("test-secret-value-12345")
expectedValue := make([]byte, len(originalValue))
copy(expectedValue, originalValue)
originalBuffer := memguard.NewBufferFromBytes(originalValue)
defer originalBuffer.Destroy()
err = sv.Save(originalBuffer)
require.NoError(t, err)
// Retrieve the value
retrievedBuffer, err := sv.GetValue(ltIdentity)
require.NoError(t, err)
defer retrievedBuffer.Destroy()
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
}
func TestListVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
versionsDir := filepath.Join(secretDir, "versions")
// No versions directory
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Empty(t, versions)
// Create some versions
testVersions := []string{
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
}
for _, v := range testVersions {
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
require.NoError(t, err)
}
// Create a file (not directory) that should be ignored
err = afero.WriteFile(
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
require.NoError(t, err)
// List versions
versions, err = secret.ListVersions(fs, secretDir)
require.NoError(t, err)
// Should be sorted in reverse chronological order
expected := []string{
"20231216.001", "20231215.002", "20231215.001", "20231214.001",
}
assert.Equal(t, expected, versions)
}
func TestGetCurrentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
// The current file contains just the version name
currentPath := filepath.Join(secretDir, "current")
err := fs.MkdirAll(secretDir, 0o755)
require.NoError(t, err)
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
require.NoError(t, err)
version, err := secret.GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231216.001", version)
}
func TestSetCurrentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
err := fs.MkdirAll(secretDir, 0o755)
require.NoError(t, err)
// Set current version
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
require.NoError(t, err)
// Verify it was set
version, err := secret.GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231216.002", version)
// Update to different version
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
require.NoError(t, err)
version, err = secret.GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231217.001", version)
}
func TestVersionMetadataTimestamps(t *testing.T) {
t.Parallel()
// Test that all timestamp fields behave consistently as pointers
vm := secret.VersionMetadata{
ID: "test-id",
}
// All should be nil initially
assert.Nil(t, vm.CreatedAt)
assert.Nil(t, vm.NotBefore)
assert.Nil(t, vm.NotAfter)
// Set timestamps
now := time.Now()
epoch := time.Unix(1, 0)
future := now.Add(time.Hour)
vm.CreatedAt = &now
vm.NotBefore = &epoch
vm.NotAfter = &future
// All should be non-nil
assert.NotNil(t, vm.CreatedAt)
assert.NotNil(t, vm.NotBefore)
assert.NotNil(t, vm.NotAfter)
// Values should match
assert.Equal(t, now.Unix(), vm.CreatedAt.Unix())
assert.Equal(t, int64(1), vm.NotBefore.Unix())
assert.Equal(t, future.Unix(), vm.NotAfter.Unix())
}
// Helper function
func fileExists(fs afero.Fs, path string) bool {
exists, _ := afero.Exists(fs, path)
return exists
}
+79
View File
@@ -0,0 +1,79 @@
package vault
import "errors"
// Sentinel errors returned by vault operations.
//
// Several of these carry deliberately partial text: the message a caller
// composes with fmt.Errorf places the interpolated value where it has
// always appeared, and the sentinel supplies only the surrounding fixed
// words. This keeps every composed message byte-identical to the dynamic
// errors these sentinels replaced. Each such sentinel notes the message it
// participates in.
var (
// ErrMnemonicMismatch indicates the mnemonic-derived public key does
// not match the vault's stored public key hash.
ErrMnemonicMismatch = errors.New(
"derived public key does not match vault: mnemonic may be incorrect",
)
// ErrInvalidVaultName indicates a vault name that breaks the naming
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
// empty, "." or "..". Composed by ValidateVaultName as
// "invalid vault name '<name>': <the rule>".
ErrInvalidVaultName = errors.New("invalid vault name")
// ErrVaultNotFound indicates the named vault does not exist. Composed
// as "vault <name> does not exist".
ErrVaultNotFound = errors.New("does not exist")
// ErrVaultExists indicates that a vault to be created already exists.
// Composed as "vault <name> already exists".
ErrVaultExists = errors.New("already exists")
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
// passphrase for an unlocker but no mnemonic to derive the long-term key
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrInvalidSecretName indicates a secret name that breaks the naming
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
// no leading '.' or '/', no trailing '/', no '//', no '..' path segment.
// Composed by ValidateSecretName as
// "invalid secret name '<name>': <the rule>".
ErrInvalidSecretName = errors.New("invalid secret name")
// ErrSecretExists indicates the secret already exists and --force
// was not supplied. Composed as
// "secret <name> already exists (use --force to overwrite)", or as
// "secret '<name>' already exists in vault '<vault>' (use --force to
// overwrite)" when copying between vaults.
ErrSecretExists = errors.New("already exists")
// ErrSecretNotFound indicates the named secret does not exist.
// Composed as "secret <name> not found".
ErrSecretNotFound = errors.New("not found")
// ErrVersionNotFound indicates the requested secret version does not
// exist. Composed as
// "version '<version>' not found for secret '<name>'".
ErrVersionNotFound = errors.New("not found for secret")
// ErrNoVersions indicates the source secret has no versions. Composed
// as "source secret '<name>' has no versions".
ErrNoVersions = errors.New("has no versions")
// ErrUnsupportedUnlockerType indicates an unlocker metadata type
// that this build does not support.
ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type")
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
// Composed as "unlocker with ID <id> not found".
ErrUnlockerNotFound = errors.New("not found")
// ErrNoLockForFilesystem indicates LockStateDir was given a filesystem
// it cannot lock. Composed as "cannot lock the state directory on
// filesystem <type>".
ErrNoLockForFilesystem = errors.New(
"cannot lock the state directory on filesystem")
)
+140
View File
@@ -0,0 +1,140 @@
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 {
_, err := vlt.RemoveUnlocker(missingName)
return err
}, 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)
}
+465
View File
@@ -0,0 +1,465 @@
package vault_test
import (
"bytes"
"errors"
"os"
"path/filepath"
"slices"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"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
// from testMnemonic using the derivation index stored in its metadata.
func deriveVaultIdentity(
t *testing.T, fs afero.Fs, vlt *vault.Vault,
) *age.X25519Identity {
t.Helper()
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load vault metadata: %v", err)
}
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
vaultMetadata.DerivationIndex)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
return ltIdentity
}
func TestVaultWithRealFilesystem(t *testing.T) {
t.Parallel()
// Create a temporary directory for our tests
tempDir := t.TempDir()
// Use the real filesystem
fs := afero.NewOsFs()
// Test currentvault file handling (plain file with relative path)
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
t.Parallel()
testCurrentVaultFileHandling(t, fs, tempDir)
})
// Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) {
t.Parallel()
testDeepPathSecrets(t, fs, tempDir)
})
// Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) {
t.Parallel()
testKeyCaching(t, fs, tempDir)
})
// Test vault name validation
t.Run("VaultNameValidation", func(t *testing.T) {
t.Parallel()
testVaultNameValidation(t, fs, tempDir)
})
// Test multiple vaults and switching between them
t.Run("MultipleVaults", func(t *testing.T) {
t.Parallel()
testMultipleVaults(t, fs, tempDir)
})
// Test adding a secret in one vault and verifying it's not visible in
// another
t.Run("VaultIsolation", func(t *testing.T) {
t.Parallel()
testVaultIsolation(t, fs, tempDir)
})
}
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "currentvault-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Verify the currentvault file exists and contains just the vault name
currentVaultPath := filepath.Join(stateDir, "currentvault")
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
if err != nil {
t.Fatalf("Failed to read currentvault file: %v", err)
}
if string(currentVaultContents) != testVaultName {
t.Errorf("Expected currentvault to contain %q, got %q",
testVaultName, string(currentVaultContents))
}
// Test that ResolveVaultSymlink correctly resolves the path
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
if err != nil {
t.Fatalf("Failed to resolve currentvault path: %v", err)
}
if resolvedPath != vaultDir {
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
}
}
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "deep-path-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Load vault metadata to get its derivation index
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load vault metadata: %v", err)
}
// Derive long-term key from mnemonic using the vault's derivation index
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
vaultMetadata.DerivationIndex)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a secret with a deeply nested path
deepPath := "api/credentials/production/database/primary"
secretValue := []byte("supersecretdbpassword")
expectedValue := make([]byte, len(secretValue))
copy(expectedValue, secretValue)
secretBuffer := memguard.NewBufferFromBytes(secretValue)
defer secretBuffer.Destroy()
err = vlt.AddSecret(deepPath, secretBuffer, false)
if err != nil {
t.Fatalf("Failed to add secret with deep path: %v", err)
}
// List secrets and verify our deep path secret is there
secrets, err := vlt.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets: %v", err)
}
if !slices.Contains(secrets, deepPath) {
t.Errorf("Deep path secret not found in listed secrets")
}
// Retrieve the secret and verify its value
retrievedValue, err := vlt.GetSecret(deepPath)
if err != nil {
t.Fatalf("Failed to retrieve deep path secret: %v", err)
}
defer retrievedValue.Destroy()
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
expectedValue, retrievedValue.Bytes())
}
}
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "key-cache-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Load vault metadata to get its derivation index
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load vault metadata: %v", err)
}
// Derive long-term key from mnemonic for verification using the
// vault's derivation index
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
vaultMetadata.DerivationIndex)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Verify the vault is locked initially
if !vlt.Locked() {
t.Errorf("Vault should be locked initially")
}
// First call to GetOrDeriveLongTermKey should derive and cache the key
firstKey, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
t.Fatalf("Failed to get long-term key: %v", err)
}
// Verify the vault is now unlocked
if vlt.Locked() {
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
}
// Second call should return the cached key without re-deriving
secondKey, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
t.Fatalf("Failed to get cached long-term key: %v", err)
}
// Verify both keys are the same instance
if firstKey != secondKey {
t.Errorf("Second key call should return same instance as first call")
}
// Verify the public key matches what we expect
expectedPubKey := ltIdentity.Recipient().String()
actualPubKey := firstKey.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
// Now clear the key and verify it's locked again
vlt.ClearLongTermKey()
if !vlt.Locked() {
t.Errorf("Vault should be locked after clearing key")
}
// Get the key again and verify it works
thirdKey, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
t.Fatalf("Failed to re-derive long-term key: %v", err)
}
// Verify the public key still matches
actualPubKey = thirdKey.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
}
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "name-validation-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Test valid vault names
validNames := []string{
"default",
"test-vault",
"production.vault",
"vault_123",
"a-very-long-vault-name-with-dashes",
}
for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
}
}
// Test invalid vault names
invalidNames := []string{
"", // Empty
"UPPERCASE", // Uppercase not allowed
"invalid/name", // Slashes not allowed in vault names
"invalid name", // Spaces not allowed
"invalid@name", // Special chars not allowed
}
for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if !errors.Is(err, vault.ErrInvalidVaultName) {
t.Errorf("Expected ErrInvalidVaultName creating vault with "+
"invalid name %q, got %v", name, err)
}
}
}
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "multi-vault-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err)
}
}
// List vaults and verify all three are there
vaults, err := vault.ListVaults(fs, stateDir)
if err != nil {
t.Fatalf("Failed to list vaults: %v", err)
}
if len(vaults) != 3 {
t.Errorf("Expected 3 vaults, got %d", len(vaults))
}
// Test switching between vaults
for _, name := range vaultNames {
// Select the vault
err := vault.SelectVault(fs, stateDir, name)
if err != nil {
t.Fatalf("Failed to select vault %s: %v", name, err)
}
// Get current vault and verify it's the one we selected
currentVault, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
t.Fatalf("Failed to get current vault after selecting %s: %v",
name, err)
}
if currentVault.GetName() != name {
t.Errorf("Expected current vault to be %s, got %s",
name, currentVault.GetName())
}
}
}
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "isolation-test")
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create two vaults - CreateVault writes the public key derived from
// the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault1: %v", err)
}
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault2: %v", err)
}
// Derive long-term keys from mnemonic
// Note: Both vaults will have different derivation indexes due to
// GetNextDerivationIndex
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
// Unlock the vaults with their respective keys
vault1.Unlock(ltIdentity1)
vault2.Unlock(ltIdentity2)
// Add a secret to vault1
secretValue := []byte("secret in vault1")
secretBuffer := memguard.NewBufferFromBytes(secretValue)
defer secretBuffer.Destroy()
err = vault1.AddSecret(testSecretName, secretBuffer, false)
if err != nil {
t.Fatalf("Failed to add secret to vault1: %v", err)
}
// Verify the secret exists in vault1
vault1Secrets, err := vault1.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets in vault1: %v", err)
}
if !slices.Contains(vault1Secrets, testSecretName) {
t.Errorf("Secret not found in vault1")
}
// Verify the secret does NOT exist in vault2
vault2Secrets, err := vault2.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets in vault2: %v", err)
}
if slices.Contains(vault2Secrets, testSecretName) {
t.Errorf("Secret from vault1 should not be visible in vault2")
}
}
+444
View File
@@ -0,0 +1,444 @@
// Version Support Integration Tests
//
// Comprehensive integration tests for version functionality:
//
// - TestVersionIntegrationWorkflow: End-to-end workflow testing
// - Creating initial version with proper metadata
// - Creating multiple versions with timestamp updates
// - Retrieving specific versions by name
// - Promoting old versions to current
// - Testing version serial number limits (999/day)
// - Error cases and edge conditions
//
// - TestVersionConcurrency: Tests concurrent read operations
//
// - TestVersionCompatibility: Tests handling of legacy non-versioned secrets
//
// Test Environment:
// - Uses in-memory filesystem (afero.MemMapFs)
// - Consistent test mnemonic for reproducible keys
// - Proper cleanup and isolation between tests
//nolint:testpackage // uses white-box test helpers shared with this package
package vault
import (
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"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
// does not match the expected contents.
var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow
//
//nolint:paralleltest // the subtests are steps that build on each other
func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs()
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Unlock the vault
vault.Unlock(ltIdentity)
secretName := "integration/test"
// Step 1: Create initial version
t.Run("create_initial_version", func(t *testing.T) {
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
})
// Step 2: Create second version
t.Run("create_second_version", func(t *testing.T) {
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
})
// Step 3: Create third version
t.Run("create_third_version", func(t *testing.T) {
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
})
// Step 4: Retrieve specific versions
t.Run("retrieve_specific_versions", func(t *testing.T) {
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
})
// Step 5: Promote old version to current
t.Run("promote_old_version", func(t *testing.T) {
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
})
// Step 6: Test version limits
t.Run("version_serial_limits", func(t *testing.T) {
testVersionSerialLimits(t, fs, vault, vaultDir)
})
// Step 7: Test error cases
t.Run("error_cases", func(t *testing.T) {
testVersionErrorCases(t, vault, secretName)
})
}
func testCreateInitialVersion(
t *testing.T, fs afero.Fs, vault *Vault,
ltIdentity *age.X25519Identity, vaultDir, secretName string,
) {
t.Helper()
addTestSecretToVault(t, vault, secretName, []byte("version-1-data"), false)
// Verify secret can be retrieved
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify version directory structure
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 1)
// Verify current symlink exists
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, versions[0], currentVersion)
// Verify metadata
version := secret.NewVersion(vault, secretName, versions[0])
err = version.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, version.Metadata.CreatedAt)
assert.NotNil(t, version.Metadata.NotBefore)
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
// NotAfter should be nil for current version
assert.Nil(t, version.Metadata.NotAfter)
}
func testCreateSecondVersion(
t *testing.T, fs afero.Fs, vault *Vault,
ltIdentity *age.X25519Identity, vaultDir, secretName string,
) {
t.Helper()
// Small delay to ensure different timestamps
time.Sleep(10 * time.Millisecond)
// Get first version name before creating second
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
firstVersionName := versions[0]
// Create second version
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
// Verify new value is current
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-2-data"), value.Bytes())
// Verify we now have two versions
versions, err = secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 2)
// Verify first version metadata was updated with notAfter
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, firstVersion.Metadata.NotAfter)
// Verify second version metadata
secondVersion := secret.NewVersion(vault, secretName, versions[0])
err = secondVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, secondVersion.Metadata.NotBefore)
assert.Nil(t, secondVersion.Metadata.NotAfter)
// NotBefore of second should equal NotAfter of first
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
secondVersion.Metadata.NotBefore.Unix())
}
func testCreateThirdVersion(
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
) {
t.Helper()
time.Sleep(10 * time.Millisecond)
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
// Verify we now have three versions
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 3)
// Current should be version-3
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-3-data"), value.Bytes())
}
func testRetrieveSpecificVersions(
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
) {
t.Helper()
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 3)
// Get each version by its name
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
require.NoError(t, err)
defer value1.Destroy()
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
require.NoError(t, err)
defer value2.Destroy()
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
require.NoError(t, err)
defer value3.Destroy()
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
// An empty version is not one of the versions; GetSecret gets the
// current one
_, err = vault.GetSecretVersion(secretName, "")
require.ErrorIs(t, err, ErrVersionNotFound)
}
func testPromoteOldVersion(
t *testing.T, fs afero.Fs, vault *Vault,
ltIdentity *age.X25519Identity, vaultDir, secretName string,
) {
t.Helper()
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
// Promote the first version (oldest) to current
oldestVersion := versions[2]
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
require.NoError(t, err)
// Verify current now returns the old version's value
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify the version metadata hasn't changed
// (promoting shouldn't modify timestamps)
version := secret.NewVersion(vault, secretName, oldestVersion)
err = version.LoadMetadata(ltIdentity)
require.NoError(t, err)
// should still have its old notAfter
assert.NotNil(t, version.Metadata.NotAfter)
}
func testVersionSerialLimits(
t *testing.T, fs afero.Fs, vault *Vault, vaultDir string,
) {
t.Helper()
// Create a new secret for this test
limitSecretName := "limit/test"
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
// Create 998 versions (we already have one from the first AddSecret)
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
// Get today's date for consistent version names
today := time.Now().Format("20060102")
// Manually create many versions with same date
for i := 2; i <= 998; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
versionDir := filepath.Join(secretDir, versionName)
err := fs.MkdirAll(versionDir, 0o755)
require.NoError(t, err)
}
// Should be able to create one more (999)
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
require.NoError(t, err)
assert.Equal(t, today+".999", versionName)
// Create the 999th version directory
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
require.NoError(t, err)
// 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))
require.Error(t, err)
}
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
t.Helper()
// Try to get non-existent version
_, err := vault.GetSecretVersion(secretName, "99991231.999")
require.ErrorIs(t, err, ErrVersionNotFound)
// Try to get version of non-existent secret
_, err = vault.GetSecretVersion("nonexistent/secret", "")
require.ErrorIs(t, err, ErrSecretNotFound)
// Try to add secret without force when it exists
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
defer failBuffer.Destroy()
err = vault.AddSecret(secretName, failBuffer, false)
require.ErrorIs(t, err, ErrSecretExists)
}
// TestVersionConcurrency tests concurrent version operations
func TestVersionConcurrency(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
vault := createTestVaultWithKey(t, fs)
secretName := "concurrent/test"
// Create initial version
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
// Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) {
t.Parallel()
done := make(chan bool, 10)
errCh := make(chan error, 10)
for range 10 {
go func() {
value, err := vault.GetSecret(secretName)
if err != nil {
errCh <- err
} else {
if value.String() != "initial" {
errCh <- fmt.Errorf("%w: %s",
errUnexpectedValue, value.Bytes())
}
value.Destroy()
}
done <- true
}()
}
// Wait for all goroutines
for range 10 {
<-done
}
// Check for errors
select {
case err := <-errCh:
t.Fatalf("concurrent read failed: %v", err)
default:
// No errors
}
})
}
// TestVersionCompatibility tests that old secrets without versions still work
func TestVersionCompatibility(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
vault := createTestVaultWithKey(t, fs)
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
// Manually create an old-style secret (no versions)
secretName := "legacy/secret"
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
err = fs.MkdirAll(secretDir, 0o755)
require.NoError(t, err)
// Create old-style encrypted value directly in secret directory
testValue := []byte("legacy-value")
testValueBuffer := memguard.NewBufferFromBytes(testValue)
defer testValueBuffer.Destroy()
ltRecipient := ltIdentity.Recipient()
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
require.NoError(t, err)
valuePath := filepath.Join(secretDir, "value.age")
err = afero.WriteFile(fs, valuePath, encrypted, 0o600)
require.NoError(t, err)
// Should fail to get with version-aware methods
_, err = vault.GetSecret(secretName)
require.Error(t, err)
// List versions should return empty
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Empty(t, versions)
}
+166
View File
@@ -0,0 +1,166 @@
package vault
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"syscall"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
)
// lockFileName is the file in the state directory that LockStateDir locks.
const lockFileName = "lock"
// finishedMark is what the lock file holds once the command that last held
// the lock has released it. A command killed while holding it leaves the
// file empty.
const finishedMark = "finished\n"
// memFsLock stands in for the lock file on the in-memory filesystem, which
// has no file locks. Every in-memory filesystem in the process shares it.
//
//nolint:gochecknoglobals // must outlive the call that takes it
var memFsLock sync.Mutex
// LockStateDir takes the lock that a command changing anything under
// stateDir holds until it returns, and returns the function that releases
// it. While one command holds it, the next one waits here. Reads take no
// lock: each file or directory a command changes is replaced in a single
// rename, so a reader finds it as it was before or after, never half-made.
// Once it holds the lock, it empties the lock file, and the function it
// returns writes finishedMark there just before releasing the lock, so a
// command killed while holding the lock leaves the mark missing. Finding it
// missing, LockStateDir first deletes the temporary files and directories
// such a command may have left, since no command still using them can be
// running. After a command that finished, it searches nothing.
//
// On the real filesystem the lock is flock(2) on the file "lock" in
// stateDir, which the kernel releases when the process dies, so a killed
// command never leaves the tool locked. The in-memory filesystem the tests
// use has no file locks, so a process-wide mutex stands in for flock there.
// Any other filesystem is refused rather than left unlocked.
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
var release func()
switch fs.(type) {
case *afero.OsFs:
var err error
release, err = flockStateDir(stateDir)
if err != nil {
return nil, err
}
case *afero.MemMapFs:
memFsLock.Lock()
release = memFsLock.Unlock
default:
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
}
// The lock file is written in place, never replaced: a command waiting
// for flock on the old file would then take a lock nobody else checks.
lockPath := filepath.Join(stateDir, lockFileName)
mark, err := afero.ReadFile(fs, lockPath)
if err != nil || string(mark) != finishedMark {
removeLeftovers(fs, stateDir)
}
err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms)
if err != nil {
release()
return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err)
}
return func() {
// If this fails, the next command searches when it need not.
_ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms)
release()
}, nil
}
// removeLeftovers deletes the temporary files and directories that commands
// killed part-way left in each directory where secret.WriteFileAtomic and
// secret.TempDirFor make them: the state directory, each vault, each secret
// and each version. Unlocker directories are written whole by
// secret.WriteDir and never changed after, so they hold none. A failure is
// only warned about, and the command goes on.
func removeLeftovers(fs afero.Fs, stateDir string) {
dirs := []string{stateDir}
for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) {
dirs = append(dirs, vaultDir)
for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) {
dirs = append(dirs, secretDir)
dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...)
}
}
for _, dir := range dirs {
err := secret.RemoveLeftovers(fs, dir)
if err != nil {
secret.Warn("Failed to remove what an interrupted command left",
"error", err)
}
}
}
// subdirs returns the directories in dir: none if dir does not exist, and
// none, with a warning, if it cannot be read.
func subdirs(fs afero.Fs, dir string) []string {
entries, err := afero.ReadDir(fs, dir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
secret.Warn("Failed to look for what an interrupted command left",
"directory", dir, "error", err)
}
return nil
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(dir, entry.Name()))
}
}
return dirs
}
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
// directory and the file if needed. Go opens files close-on-exec, so
// programs the command runs, such as gpg, do not inherit the lock.
func flockStateDir(stateDir string) (func(), error) {
err := os.MkdirAll(stateDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create state directory: %w", err)
}
lockPath := filepath.Join(stateDir, lockFileName)
//nolint:gosec // G304: the path is the lock file in the state directory
file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, secret.FilePerms)
if err != nil {
return nil, fmt.Errorf("failed to open lock file: %w", err)
}
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("failed to lock %s: %w", lockPath, err)
}
// Closing the file releases the lock.
return func() { _ = file.Close() }, nil
}
+181
View File
@@ -0,0 +1,181 @@
package vault_test
import (
"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"
)
const (
// lockWait is how long a test waits for the lock before deciding it
// will never come free.
lockWait = 10 * time.Second
// heldWait is how long a test watches a second holder fail to take a
// lock that is held. Broken exclusion lets it in at once.
heldWait = 100 * time.Millisecond
)
// lockFilesystem is a filesystem LockStateDir can lock, with a state
// directory on it.
type lockFilesystem struct {
name string
fs afero.Fs
stateDir string
}
// lockFilesystems returns the real filesystem, locked with flock, and the
// in-memory one, locked with a mutex.
func lockFilesystems(t *testing.T) []lockFilesystem {
t.Helper()
return []lockFilesystem{
{"memory", afero.NewMemMapFs(), testStateDir},
{"real", afero.NewOsFs(), t.TempDir()},
}
}
// lockInBackground starts taking the lock and returns a channel that
// delivers the function releasing it once it has been taken.
func lockInBackground(
t *testing.T, fs afero.Fs, stateDir string,
) <-chan func() {
t.Helper()
taken := make(chan func(), 1)
go func() {
release, err := vault.LockStateDir(fs, stateDir)
if assert.NoError(t, err) {
taken <- release
}
}()
return taken
}
// TestLockStateDirExcludes checks that while the lock is held a second
// holder, with its own open lock file on the real filesystem, waits, and
// that it gets the lock once the first releases it.
func TestLockStateDirExcludes(t *testing.T) {
t.Parallel()
for _, lfs := range lockFilesystems(t) {
t.Run(lfs.name, func(t *testing.T) {
t.Parallel()
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
taken := lockInBackground(t, lfs.fs, lfs.stateDir)
select {
case second := <-taken:
second()
release()
t.Fatal("a second holder took the lock while it was held")
case <-time.After(heldWait):
}
release()
select {
case second := <-taken:
second()
case <-time.After(lockWait):
t.Fatal("the second holder never got the lock")
}
})
}
}
// TestLockStateDirFreeAfterPanic checks that a holder that panics, and
// releases the lock with defer as every command does, leaves it free.
func TestLockStateDirFreeAfterPanic(t *testing.T) {
t.Parallel()
for _, lfs := range lockFilesystems(t) {
t.Run(lfs.name, func(t *testing.T) {
t.Parallel()
assert.Panics(t, func() {
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
defer release()
panic("the command failed")
})
select {
case release := <-lockInBackground(t, lfs.fs, lfs.stateDir):
release()
case <-time.After(lockWait):
t.Fatal("the lock was still held after its holder panicked")
}
})
}
}
// TestLockStateDirRemovesLeftoversOnlyAfterKill checks that taking the lock
// deletes a temporary directory a killed command left only when the last
// holder of the lock did not release it. A holder killed while it holds the
// lock leaves the lock file as it is at that moment.
func TestLockStateDirRemovesLeftoversOnlyAfterKill(t *testing.T) {
t.Parallel()
for _, lfs := range lockFilesystems(t) {
t.Run(lfs.name, func(t *testing.T) {
t.Parallel()
lockFile := filepath.Join(lfs.stateDir, "lock")
leftover := filepath.Join(lfs.stateDir, ".tmp-1")
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
whileHeld, err := afero.ReadFile(lfs.fs, lockFile)
require.NoError(t, err)
release()
require.NoError(t, lfs.fs.MkdirAll(leftover, secret.DirPerms))
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
release()
exists, err := afero.DirExists(lfs.fs, leftover)
require.NoError(t, err)
assert.True(t, exists, "searched after a holder that finished")
require.NoError(t, afero.WriteFile(lfs.fs, lockFile, whileHeld,
secret.FilePerms))
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
release()
exists, err = afero.DirExists(lfs.fs, leftover)
require.NoError(t, err)
assert.False(t, exists, "not searched after a holder that was killed")
})
}
}
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
// lock implementation is refused instead of being used unlocked.
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
t.Parallel()
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
release, err := vault.LockStateDir(fs, testStateDir)
require.ErrorIs(t, err, vault.ErrNoLockForFilesystem)
assert.Nil(t, release)
}
+366
View File
@@ -0,0 +1,366 @@
// Package vault provides functionality for managing encrypted vaults.
package vault
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// Register the GetCurrentVault function with the secret package
//
//nolint:gochecknoinits // registers the vault accessor with the secret package
func init() {
secret.RegisterGetCurrentVaultFunc(
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
return GetCurrentVault(fs, stateDir)
})
}
// isValidVaultName reports whether name is a valid vault name: only
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
// "..". With no path separator allowed, a vault is always one directory
// directly under vaults.d.
func isValidVaultName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
return matched
}
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
// is not a valid vault name. Call it on the name exactly as the user gave it,
// before building any path from it.
func ValidateVaultName(name string) error {
if !isValidVaultName(name) {
return fmt.Errorf(
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
"are allowed, and a name must not be empty, '.' or '..'",
ErrInvalidVaultName, name,
)
}
return nil
}
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
// The file contains just the vault name (e.g., "default")
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
fileData, err := afero.ReadFile(fs, currentVaultPath)
if err != nil {
secret.Debug("Failed to read currentvault file", "error", err)
return "", fmt.Errorf("failed to read currentvault file: %w", err)
}
// The file contains just the vault name like "default"
vaultName := strings.TrimSpace(string(fileData))
secret.Debug("Read vault name from file", "vault_name", vaultName)
// Resolve to absolute path: stateDir/vaults.d/vaultName
stateDir := filepath.Dir(currentVaultPath)
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
return absolutePath, nil
}
// GetCurrentVault gets the current vault from the file system
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
secret.Debug("Getting current vault", "state_dir", stateDir)
// Check if the current vault symlink exists
currentVaultPath := filepath.Join(stateDir, "currentvault")
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
_, err := fs.Stat(currentVaultPath)
if err != nil {
secret.Debug("Failed to stat current vault symlink",
"error", err, "path", currentVaultPath)
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
}
secret.Debug("Current vault symlink exists")
// Resolve the symlink to get the actual vault directory
secret.Debug("Resolving vault symlink")
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
if err != nil {
return nil, err
}
secret.Debug("Resolved vault symlink", "target_path", targetPath)
// Extract the vault name from the path
// The path will be something like "/path/to/vaults.d/default"
vaultName := filepath.Base(targetPath)
secret.Debug("Extracted vault name", "vault_name", vaultName)
secret.Debug("Current vault resolved",
"vault_name", vaultName, "target_path", targetPath)
// Create and return the vault
return NewVault(fs, stateDir, vaultName), nil
}
// ListVaults lists all vaults in the state directory
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
}
if !exists {
return []string{}, nil
}
// Read the vaults directory
entries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
}
// Extract vault names
var vaults []string
for _, entry := range entries {
if entry.IsDir() {
vaults = append(vaults, entry.Name())
}
}
return vaults, nil
}
// processMnemonicForVault handles mnemonic processing for vault creation.
// It returns the long-term key, nil when there is no mnemonic, and the
// derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (*age.X25519Identity, uint32, string, string, error) {
if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
"vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided
return nil, 0, "", "", nil
}
mnemonic := mnemonicBuffer.String()
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
if err != nil {
return nil, 0, "", "",
fmt.Errorf("failed to get next derivation index: %w", err)
}
// Derive the long-term key using the actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil {
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
}
// Write the public key
ltPubKey := ltIdentity.Recipient().String()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil {
return nil, 0, "", "",
fmt.Errorf("failed to write long-term public key: %w", err)
}
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
// Compute verification hash from actual derivation index
publicKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
// Compute family hash from index 0 (same for all vaults with this mnemonic)
// This is used to identify which vaults belong to the same mnemonic family
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return nil, 0, "", "",
fmt.Errorf("failed to derive identity for index 0: %w", err)
}
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. When passphrase is not nil, the vault
// gets a passphrase unlocker protected by it, as its current unlocker; that
// needs a mnemonic. It refuses a vault that already exists before writing
// anything: creating it again would replace its keys, and its secrets could
// no longer be decrypted. The commands that call it hold the state directory
// lock, so no other command can create the vault between the check and the
// writes.
//
// The vault is written whole into a temporary directory, which is renamed
// into vaults.d only once complete, and only then selected: a crash at any
// point leaves either no vault or a complete one. The next command that
// takes the lock deletes what the crash left under a temporary name.
func CreateVault(
fs afero.Fs, stateDir string, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, err
}
secret.Debug("Vault name validation passed", "vault_name", name)
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
if passphrase != nil && mnemonic == nil {
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
}
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
})
if err != nil {
return nil, err
}
// Select the newly created vault as current
secret.Debug("Selecting newly created vault as current", "name", name)
err = SelectVault(fs, stateDir, name)
if err != nil {
return nil, fmt.Errorf("failed to select vault: %w", err)
}
// Create and return the vault
secret.Debug("Successfully created vault", "name", name)
vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
}
// writeVaultFiles writes the files of the new vault name into vaultDir: its
// secrets and unlockers directories, its long-term public key and metadata,
// and, when passphrase is not nil, a passphrase unlocker as its current one.
func writeVaultFiles(
fs afero.Fs, stateDir, vaultDir, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) error {
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return err
}
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return fmt.Errorf("failed to save vault metadata: %w", err)
}
if passphrase == nil {
return nil
}
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
return err
}
// SelectVault selects the given vault as the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error {
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return err
}
secret.Debug("Vault name validation passed", "vault_name", name)
// Check if vault exists
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return fmt.Errorf("failed to check if vault exists: %w", err)
}
if !exists {
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
}
// Create or replace the currentvault file with just the vault name. It
// is replaced in one rename, so it never goes missing.
currentVaultPath := filepath.Join(stateDir, "currentvault")
secret.Debug("Writing currentvault file", "vault_name", name)
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
if err != nil {
return fmt.Errorf("failed to select vault: %w", err)
}
secret.Debug("Successfully selected vault", "vault_name", name)
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package vault
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// Metadata is an alias for secret.VaultMetadata
type Metadata = secret.VaultMetadata
// UnlockerMetadata is an alias for secret.UnlockerMetadata
type UnlockerMetadata = secret.UnlockerMetadata
// SecretMetadata is an alias for secret.Metadata
type SecretMetadata = secret.Metadata
// Configuration is an alias for secret.Configuration
type Configuration = secret.Configuration
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
func ComputeDoubleSHA256(data []byte) string {
firstHash := sha256.Sum256(data)
secondHash := sha256.Sum256(firstHash[:])
return hex.EncodeToString(secondHash[:])
}
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
// by deriving the public key for index 0 and using its hash to identify related vaults
func GetNextDerivationIndex(
fs afero.Fs, stateDir string, mnemonic string,
) (uint32, error) {
// First, derive the public key for index 0 to get our identifier
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
}
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir)
if err != nil {
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
}
if !exists {
// No vaults yet, start with index 0
return 0, nil
}
// Read all vault directories
entries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return 0, fmt.Errorf("failed to read vaults directory: %w", err)
}
// Track which indices are in use for this mnemonic
usedIndices := make(map[uint32]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Try to read vault metadata
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
// Skip vaults without metadata
continue
}
var metadata Metadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
// Skip vaults with invalid metadata
continue
}
// Check if this vault uses the same mnemonic by comparing family hashes
if metadata.MnemonicFamilyHash == pubKeyHash {
usedIndices[metadata.DerivationIndex] = true
}
}
// Find the first available index
var index uint32
for usedIndices[index] {
index++
}
return index, nil
}
// SaveVaultMetadata saves vault metadata to the vault directory
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vault metadata: %w", err)
}
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
if err != nil {
return fmt.Errorf("failed to write vault metadata: %w", err)
}
return nil
}
// LoadVaultMetadata loads vault metadata from the vault directory
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata Metadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
}
return &metadata, nil
}
+456
View File
@@ -0,0 +1,456 @@
package vault_test
import (
"path/filepath"
"strings"
"testing"
"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
func TestVaultMetadata(t *testing.T) {
fs := afero.NewMemMapFs()
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
testComputeDoubleSHA256(t)
})
t.Run("GetNextDerivationIndex", func(t *testing.T) {
testGetNextDerivationIndex(t, fs)
})
t.Run("MetadataPersistence", func(t *testing.T) {
testMetadataPersistence(t, fs)
})
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
testDifferentKeysForDifferentIndices(t)
})
}
func testComputeDoubleSHA256(t *testing.T) {
t.Helper()
// Test data
data := []byte("test data")
hash := vault.ComputeDoubleSHA256(data)
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
if len(hash) != 64 {
t.Errorf("Expected hash length of 64, got %d", len(hash))
}
// Verify consistency
hash2 := vault.ComputeDoubleSHA256(data)
if hash != hash2 {
t.Errorf("Hash should be consistent for same input")
}
// Verify different input produces different hash
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
if hash == hash3 {
t.Errorf("Different input should produce different hash")
}
}
// createVaultDirWithMetadata creates a vault directory containing a public
// key derived from testMnemonic at the given index plus saved metadata, and
// returns the derived public key hash. An empty familyHash defaults to the
// derived key's own hash.
func createVaultDirWithMetadata(
t *testing.T, fs afero.Fs, vaultName string,
derivationIndex uint32, familyHash string,
) string {
t.Helper()
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
err := fs.MkdirAll(vaultDir, 0o700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Derive identity for the requested index
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
if err != nil {
t.Fatalf("Failed to derive identity: %v", err)
}
pubKey := identity.Recipient().String()
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
// Write public key
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
[]byte(pubKey), 0o600)
if err != nil {
t.Fatalf("Failed to write public key: %v", err)
}
if familyHash == "" {
familyHash = pubKeyHash
}
metadata := &vault.Metadata{
DerivationIndex: derivationIndex,
PublicKeyHash: pubKeyHash,
MnemonicFamilyHash: familyHash,
}
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
return pubKeyHash
}
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
t.Helper()
// Test with no existing vaults
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 0 {
t.Errorf("Expected index 0 for first vault, got %d", index)
}
// Create a vault with metadata and matching public key (index 0; the
// family hash is the index 0 key hash)
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
// Next index for same mnemonic should be 1
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 1 {
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
}
// Different mnemonic should start at 0
//nolint:dupword // BIP39-style test mnemonic
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 0 {
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
index)
}
// Add another vault with same mnemonic but higher index (5), sharing
// the same family hash since it's from the same mnemonic
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
// Next index should be 1 (not 6): we look for the first available slot
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 1 {
t.Errorf("Expected index 1 (first available), got %d", index)
}
}
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
t.Helper()
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
err := fs.MkdirAll(vaultDir, 0o700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Create and save metadata
metadata := &vault.Metadata{
DerivationIndex: 3,
PublicKeyHash: "test-public-key-hash",
}
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
// Load and verify
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load metadata: %v", err)
}
if loaded.DerivationIndex != metadata.DerivationIndex {
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
metadata.DerivationIndex, loaded.DerivationIndex)
}
if loaded.PublicKeyHash != metadata.PublicKeyHash {
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
metadata.PublicKeyHash, loaded.PublicKeyHash)
}
}
func testDifferentKeysForDifferentIndices(t *testing.T) {
t.Helper()
// Derive keys with different indices
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity with index 0: %v", err)
}
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
if err != nil {
t.Fatalf("Failed to derive identity with index 1: %v", err)
}
// Compute public key hashes
pubKey0 := identity0.Recipient().String()
pubKey1 := identity1.Recipient().String()
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
// Verify different indices produce different public keys
if pubKey0 == pubKey1 {
t.Errorf("Different derivation indices should produce different public keys")
}
// But the hash of index 0's public key should be the same for the same
// mnemonic. This is what we use as the identifier
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
pubKey0Again := identity0Again.Recipient().String()
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
if hash0 != hash0Again {
t.Errorf("Same mnemonic should produce same public key hash for index 0")
}
}
func TestPublicKeyHashConsistency(t *testing.T) {
t.Parallel()
// Derive identity from index 0 multiple times
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive first identity: %v", err)
}
identity2, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive second identity: %v", err)
}
// Verify identities are the same
if identity1.Recipient().String() != identity2.Recipient().String() {
t.Errorf("Identity derivation is not deterministic")
t.Logf("First: %s", identity1.Recipient().String())
t.Logf("Second: %s", identity2.Recipient().String())
}
// Compute public key hashes
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
// Verify hashes are the same
if hash1 != hash2 {
t.Errorf("Public key hash computation is not deterministic")
t.Logf("First hash: %s", hash1)
t.Logf("Second hash: %s", hash2)
}
t.Logf("Test mnemonic public key hash (index 0): %s", hash1)
}
func TestSampleHashCalculation(t *testing.T) {
t.Parallel()
// Test with the exact mnemonic from integration test if available. We
// also test with a few different mnemonics to make sure they produce
// different hashes
mnemonics := []string{
testMnemonic,
"legal winner thank year wave sausage worth useful legal winner thank yellow",
//nolint:dupword // BIP39-style test mnemonic
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
}
for i, mnemonic := range mnemonics {
identity, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
t.Logf(" Recipient: %s", identity.Recipient().String())
}
}
func TestWorkflowMismatch(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing
tempDir := t.TempDir()
fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command)
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err)
}
// Load metadata for vault1
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
if err != nil {
t.Fatalf("Failed to load vault1 metadata: %v", err)
}
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err)
}
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
// Simulate the vault import process: get the next available derivation
// index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get next derivation index: %v", err)
}
t.Logf("Next derivation index for import: %d", derivationIndex)
// Calculate public key hash from index 0 (same as in VaultImport)
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity for index 0: %v", err)
}
publicKeyHash := vault.ComputeDoubleSHA256(
[]byte(identity0.Recipient().String()))
// Load existing metadata and update it (same as in VaultImport)
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
if err != nil {
t.Fatalf("Failed to load existing metadata: %v", err)
}
// Update metadata with new derivation info
existingMetadata.DerivationIndex = derivationIndex
existingMetadata.PublicKeyHash = publicKeyHash
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
if err != nil {
t.Fatalf("Failed to save vault metadata: %v", err)
}
// Load updated metadata for vault2
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
if err != nil {
t.Fatalf("Failed to load vault2 metadata: %v", err)
}
t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
metadata2.DerivationIndex, metadata2.PublicKeyHash)
// Verify that both vaults have the same public key hash
if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
t.Errorf("Public key hashes don't match!")
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
} else {
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
metadata1.PublicKeyHash)
}
}
func TestReverseEngineerHash(t *testing.T) {
t.Parallel()
// This is the hash that the work vault is getting in the failing test
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
// Calculate hash for test mnemonic
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity: %v", err)
}
calculatedHash := vault.ComputeDoubleSHA256(
[]byte(identity.Recipient().String()))
t.Logf("Test mnemonic hash: %s", calculatedHash)
if calculatedHash == correctHash {
t.Logf("Test mnemonic produces the correct hash")
} else {
t.Errorf("Test mnemonic does not produce the correct hash")
}
if calculatedHash == wrongHash {
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
}
// Try some other possibilities: maybe a string normalization issue?
variations := []string{
testMnemonic,
" " + testMnemonic + " ",
testMnemonic + "\n",
strings.TrimSpace(testMnemonic),
}
for i, variation := range variations {
identity, err := agehd.DeriveIdentity(variation, 0)
if err != nil {
t.Logf("Variation %d failed: %v", i, err)
continue
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Variation %d hash: %s", i, hash)
if hash == wrongHash {
t.Logf("Found variation that produces wrong hash: '%s'", variation)
}
}
// Maybe let's try an empty mnemonic or something else?
emptyMnemonics := []string{
"",
" ",
}
for i, emptyMnemonic := range emptyMnemonics {
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
if err != nil {
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
continue
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Empty mnemonic %d hash: %s", i, hash)
if hash == wrongHash {
t.Logf("Empty mnemonic produces wrong hash!")
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package vault_test
import (
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
)
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
err = vlt.AddSecret("legit", value, false)
require.NoError(t, err)
// These names contain path traversal and should be rejected
maliciousNames := []string{
"../../../etc/passwd",
"..%2f..%2fetc/passwd",
".secret",
"../sibling-vault/secrets.d/target",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretVersion(name, "")
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
"GetSecretVersion should reject malicious name: %s", name)
})
}
}
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
}
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
maliciousNames := []string{
"../../../etc/passwd",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretObject(name)
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
"GetSecretObject should reject: %s", name)
})
}
}
+876
View File
@@ -0,0 +1,876 @@
package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
)
// ListSecrets returns a list of secret names in this vault
func (v *Vault) ListSecrets() ([]string, error) {
secret.DebugWith("Listing secrets in vault", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for secret listing",
"error", err, "vault_name", v.Name)
return nil, err
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
// Check if secrets directory exists
exists, err := afero.DirExists(v.fs, secretsDir)
if err != nil {
secret.Debug("Failed to check secrets directory",
"error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
}
if !exists {
secret.Debug("Secrets directory does not exist",
"secrets_dir", secretsDir, "vault_name", v.Name)
return []string{}, nil
}
// List directories in secrets.d
files, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
secret.Debug("Failed to read secrets directory",
"error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
}
var secrets []string
for _, file := range files {
if file.IsDir() {
// Convert storage name back to secret name
secretName := strings.ReplaceAll(file.Name(), "%", "/")
secrets = append(secrets, secretName)
}
}
secret.DebugWith("Found secrets in vault",
slog.String("vault_name", v.Name),
slog.Int("secret_count", len(secrets)),
slog.Any("secret_names", secrets),
)
return secrets, nil
}
// isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
// but with additional restrictions:
// - No leading or trailing slashes
// - No double slashes
// - No names starting with dots
// - No ".." path segments
func isValidSecretName(name string) bool {
if name == "" {
return false
}
// Check for leading/trailing slashes
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
return false
}
// Check for double slashes
if strings.Contains(name, "//") {
return false
}
// Check for names starting with dot
if strings.HasPrefix(name, ".") {
return false
}
// Check for path traversal via ".." components
if slices.Contains(strings.Split(name, "/"), "..") {
return false
}
// Check the basic pattern
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
return matched
}
// ValidateSecretName returns an error wrapping ErrInvalidSecretName when
// name is not a valid secret name. Call it on the name exactly as the user
// gave it, before building any path from it.
func ValidateSecretName(name string) error {
if !isValidSecretName(name) {
return fmt.Errorf(
"%w '%s': only ASCII letters, digits, '.', '-', '_' and '/' are allowed, "+
"and a name must not be empty, start with '.' or '/', end with '/', "+
"contain '//', or have '..' as a path segment",
ErrInvalidSecretName, name,
)
}
return nil
}
// AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil {
return secret.ErrNilValueBuffer
}
secret.DebugWith("Adding secret to vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.Int("value_length", value.Size()),
slog.Bool("force", force),
)
// Validate secret name
err := ValidateSecretName(name)
if err != nil {
secret.Debug("Invalid secret name provided", "secret_name", name)
return err
}
secret.Debug("Secret name validation passed", "secret_name", name)
secret.Debug("Getting vault directory")
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for secret addition",
"error", err, "vault_name", v.Name)
return err
}
secret.Debug("Got vault directory", "vault_dir", vaultDir)
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
secret.DebugWith("Secret storage details",
slog.String("storage_name", storageName),
slog.String("secret_dir", secretDir),
)
// Check for an existing secret and the version the new one supersedes
exists, previousVersion, err := v.checkExistingSecret(name, secretDir, force)
if err != nil {
return err
}
if exists {
return v.addVersion(name, secretDir, value, previousVersion)
}
return v.addNewSecret(name, secretDir, value)
}
// addNewSecret creates a secret by assembling its first version and current
// pointer in a temporary directory, then renaming that directory to
// secretDir, so an interrupted add leaves no half-made secret behind.
func (v *Vault) addNewSecret(
name, secretDir string, value *memguard.LockedBuffer,
) error {
buildDir, err := secret.TempDirFor(v.fs, secretDir)
if err != nil {
return err
}
// Once the rename below has moved it into place, this finds nothing.
defer func() { _ = v.fs.RemoveAll(buildDir) }()
err = v.addVersion(name, buildDir, value, nil)
if err != nil {
return err
}
err = v.fs.Rename(buildDir, secretDir)
if err != nil {
return fmt.Errorf("failed to move new secret into place: %w", err)
}
return nil
}
// addVersion saves value as a new version under secretDir, sets the
// notAfter timestamp of the version it supersedes, if any, and then points
// current at the new version. Until that last step, current still names the
// previous version, which stays readable.
func (v *Vault) addVersion(
name, secretDir string, value *memguard.LockedBuffer,
previousVersion *secret.Version,
) error {
// Unlock the vault before writing anything, so that a wrong passphrase
// or mnemonic leaves the secret as it was
err := v.loadPreviousVersionMetadata(previousVersion)
if err != nil {
return err
}
// Taken after the unlock, which can wait for the user to type the
// passphrase, so that the new version starts when it is created
now := time.Now()
// Create the new version and save the encrypted value
versionName, err := v.createAndSaveVersion(
name, secretDir, value, previousVersion, &now)
if err != nil {
return err
}
// Update previous version's notAfter timestamp if it exists
err = v.updatePreviousVersion(previousVersion, &now)
if err != nil {
return err
}
// Set current symlink to new version
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
if err != nil {
secret.Debug("Failed to set current version", "error", err, "version", versionName)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.Debug("Successfully added secret version to vault",
"secret_name", name, "version", versionName,
"vault_name", v.Name)
return nil
}
// updateVersionMetadata updates the metadata of an existing version
func updateVersionMetadata(
fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity,
) error {
// Read the version's encrypted private key
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Decrypt version private key using long-term key
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
return fmt.Errorf("failed to parse version private key: %w", err)
}
// Marshal updated metadata
metadataBytes, err := json.MarshalIndent(version.Metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer,
versionIdentity.Recipient())
if err != nil {
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
// Write encrypted metadata
metadataPath := filepath.Join(version.Directory, "metadata.age")
err = secret.WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
return nil
}
// GetSecret retrieves the current version of a secret from this vault.
// The caller must destroy the returned buffer.
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
)
// GetSecretObject validates the name and checks that the secret exists
secretObj, err := v.GetSecretObject(name)
if err != nil {
return nil, err
}
currentVersion, err := secret.GetCurrentVersion(v.fs, secretObj.Directory)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
return v.GetSecretVersion(name, currentVersion)
}
// GetSecretVersion retrieves a specific version of a secret. The version
// must be one of the secret's versions; GetSecret gets the current one.
// The caller must destroy the returned buffer.
func (v *Vault) GetSecretVersion(
name string, version string,
) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret version from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
)
// Validate the name and check that the version exists
err := v.checkSecretVersion(name, version)
if err != nil {
return nil, err
}
// Create version object
secretVersion := secret.NewVersion(v, name, version)
secret.Debug("Version exists, proceeding with vault unlock and decryption",
"version", version, "secret_name", name)
// Unlock the vault (get long-term key in memory)
longTermIdentity, err := v.UnlockVault()
if err != nil {
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to unlock vault: %w", err)
}
secret.DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
)
// Get the version's value
secret.Debug("About to call secretVersion.GetValue",
"version", version, "secret_name", name)
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
if err != nil {
secret.Debug("Failed to decrypt version value",
"error", err, "version", version, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt version: %w", err)
}
secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", decryptedValue.Size()),
)
return decryptedValue, nil
}
// UnlockVault unlocks the vault and returns the long-term private key
func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
secret.Debug("Unlocking vault", "vault_name", v.Name)
// If vault is already unlocked, return the cached key
if !v.Locked() {
secret.Debug("Vault already unlocked, returning cached long-term key",
"vault_name", v.Name)
return v.longTermKey, nil
}
// Get or derive the long-term key (but don't store it yet)
longTermIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get or derive long-term key",
"error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
// Now unlock the vault by storing the key in memory
v.Unlock(longTermIdentity)
secret.DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("public_key", longTermIdentity.Recipient().String()),
)
return longTermIdentity, nil
}
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
err := ValidateSecretName(name)
if err != nil {
return nil, err
}
// First check if the secret exists by checking for the metadata file
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret directory exists
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
}
// Create a Secret object
secretObj := secret.NewSecret(v, name)
// Load the metadata from disk
err = secretObj.LoadMetadata()
if err != nil {
return nil, err
}
return secretObj, nil
}
// CopySecretVersion copies a single version from source into destSecretDir
// in this vault. It decrypts the value using srcIdentity and re-encrypts
// for this vault.
func (v *Vault) CopySecretVersion(
srcVersion *secret.Version,
srcIdentity *age.X25519Identity,
destSecretName string,
destSecretDir string,
destVersionName string,
) error {
secret.DebugWith("Copying secret version to vault",
slog.String("src_secret", srcVersion.SecretName),
slog.String("src_version", srcVersion.Version),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.String("dest_version", destVersionName),
)
// Get the decrypted value from source
valueBuffer, err := srcVersion.GetValue(srcIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt source version: %w", err)
}
defer valueBuffer.Destroy()
// Load source metadata
err = srcVersion.LoadMetadata(srcIdentity)
if err != nil {
return fmt.Errorf("failed to load source metadata: %w", err)
}
// Create destination version with same name
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
destVersion.Directory = filepath.Join(destSecretDir, "versions", destVersionName)
// Copy metadata (preserve original timestamps)
destVersion.Metadata = srcVersion.Metadata
// Save the version (encrypts to this vault's LT key)
err = destVersion.Save(valueBuffer)
if err != nil {
return fmt.Errorf("failed to save destination version: %w", err)
}
secret.Debug("Successfully copied secret version",
"src_version", srcVersion.Version,
"dest_version", destVersionName,
"dest_vault", v.Name)
return nil
}
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
// It re-encrypts each version with this vault's long-term key
func (v *Vault) CopySecretAllVersions(
srcVault *Vault,
srcSecretName string,
destSecretName string,
force bool,
) error {
secret.DebugWith("Copying all secret versions between vaults",
slog.String("src_vault", srcVault.Name),
slog.String("src_secret", srcSecretName),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.Bool("force", force),
)
// Get destination vault directory
destVaultDir, err := v.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Refuse to replace an existing destination secret unless forced
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
err = v.checkCopyDestination(destSecretDir, destSecretName, force)
if err != nil {
return err
}
// Get source vault's long-term key
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
}
// Get source secret directory
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
// List all versions
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to list source versions: %w", err)
}
if len(versions) == 0 {
return fmt.Errorf("source secret '%s' %w", srcSecretName, ErrNoVersions)
}
// Get current version name
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to get current version: %w", err)
}
// Copy each version and the current pointer, then move the copy into place
err = v.copyVersions(srcVault, srcIdentity,
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
if err != nil {
return err
}
secret.DebugWith("Successfully copied all secret versions",
slog.String("src_vault", srcVault.Name),
slog.String("dest_vault", v.Name),
slog.Int("version_count", len(versions)),
)
return nil
}
// checkExistingSecret reports whether the secret already exists, refuses to
// overwrite it unless force is set, and returns its current version, which
// the new version supersedes, if any.
func (v *Vault) checkExistingSecret(
name, secretDir string, force bool,
) (bool, *secret.Version, error) {
// Check if secret already exists
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists",
"error", err, "secret_dir", secretDir)
return false, nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
secret.Debug("Secret existence check complete", "exists", exists)
if !exists {
return false, nil, nil
}
if !force {
secret.Debug("Secret already exists and force not specified",
"secret_name", name, "secret_dir", secretDir)
return true, nil, fmt.Errorf(
"secret %s %w (use --force to overwrite)",
name, ErrSecretExists,
)
}
// Get the current version to update its notAfter timestamp
var previousVersion *secret.Version
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
if err == nil && currentVersionName != "" {
previousVersion = secret.NewVersion(v, name, currentVersionName)
// We'll need to load and update its metadata after we unlock the vault
}
return true, previousVersion, nil
}
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
// the version being superseded. It is a no-op when previousVersion is nil.
func (v *Vault) loadPreviousVersionMetadata(
previousVersion *secret.Version,
) error {
if previousVersion == nil {
return nil
}
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for previous version", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
err = previousVersion.LoadMetadata(ltIdentity)
if err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
return nil
}
// updatePreviousVersion sets the notAfter timestamp on the version being
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
// a no-op when previousVersion is nil.
func (v *Vault) updatePreviousVersion(
previousVersion *secret.Version, now *time.Time,
) error {
if previousVersion == nil {
return nil
}
// loadPreviousVersionMetadata unlocked the vault, so this returns the
// key it keeps in memory
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Update notAfter timestamp
previousVersion.Metadata.NotAfter = now
// Re-save the metadata (we need to implement an update method)
err = updateVersionMetadata(v.fs, previousVersion, ltIdentity)
if err != nil {
secret.Debug("Failed to update previous version metadata", "error", err)
return fmt.Errorf("failed to update previous version metadata: %w", err)
}
return nil
}
// checkSecretVersion validates the secret name and verifies that the secret
// exists and that version is one of its versions.
func (v *Vault) checkSecretVersion(name, version string) error {
// Validate secret name to prevent path traversal
err := ValidateSecretName(name)
if err != nil {
secret.Debug("Invalid secret name provided", "secret_name", name)
return err
}
// Get vault directory
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret exists
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
return fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
}
// Check if version exists
exists, err = secret.VersionExists(v.fs, secretDir, version)
if err != nil {
secret.Debug("Failed to check if version exists", "error", err, "version", version)
return fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
secret.Debug("Version not found", "version", version, "secret_name", name)
return fmt.Errorf("version '%s' %w '%s'", version, ErrVersionNotFound, name)
}
return nil
}
// createAndSaveVersion generates a new version name, sets the version
// timestamps, and saves the encrypted value under secretDir, which is a
// temporary directory while a new secret is being assembled.
func (v *Vault) createAndSaveVersion(
name, secretDir string, value *memguard.LockedBuffer,
previousVersion *secret.Version, now *time.Time,
) (string, error) {
// Generate new version name
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
return "", fmt.Errorf("failed to generate version name: %w", err)
}
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
// Create new version
newVersion := secret.NewVersion(v, name, versionName)
newVersion.Directory = filepath.Join(secretDir, "versions", versionName)
// Set version timestamps
if previousVersion == nil {
// First version: notBefore = epoch + 1 second
epochPlusOne := time.Unix(1, 0)
newVersion.Metadata.NotBefore = &epochPlusOne
} else {
// New version: notBefore = now
newVersion.Metadata.NotBefore = now
// We'll update the previous version's notAfter after we save the
// new version
}
// Save the new version - pass the LockedBuffer directly
err = newVersion.Save(value)
if err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName)
return "", fmt.Errorf("failed to save version: %w", err)
}
return versionName, nil
}
// copyVersions copies each version of the source secret and its current
// pointer into a temporary directory, then moves that directory to
// destSecretDir, replacing a secret already there. Nothing in this vault
// changes until the copy is complete, so an interrupted copy leaves only a
// temporary directory behind.
func (v *Vault) copyVersions(
srcVault *Vault, srcIdentity *age.X25519Identity,
srcSecretName, destSecretName, destSecretDir string,
versions []string, currentVersion string,
) error {
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
if err != nil {
return err
}
// Once the rename below has moved it into place, this finds nothing.
defer func() { _ = v.fs.RemoveAll(buildDir) }()
for _, versionName := range versions {
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
err = v.CopySecretVersion(
srcVersion, srcIdentity, destSecretName, buildDir, versionName)
if err != nil {
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
}
}
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
if err != nil {
return fmt.Errorf("failed to set current version: %w", err)
}
// With --force, the secret being replaced goes only now that its
// replacement is complete
exists, err := afero.DirExists(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to check destination: %w", err)
}
if exists {
secret.Debug("Removing existing destination secret", "path", destSecretDir)
err = secret.RemoveDirAtomic(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to remove existing destination secret: %w", err)
}
}
err = v.fs.Rename(buildDir, destSecretDir)
if err != nil {
return fmt.Errorf("failed to move copied secret into place: %w", err)
}
return nil
}
// checkCopyDestination refuses to copy over an existing secret unless force
// is set. A secret being replaced is removed by copyVersions, once its
// replacement is complete.
func (v *Vault) checkCopyDestination(
destSecretDir, destSecretName string, force bool,
) error {
exists, err := afero.DirExists(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to check destination: %w", err)
}
if exists && !force {
return fmt.Errorf(
"secret '%s' %w in vault '%s' (use --force to overwrite)",
destSecretName, ErrSecretExists, v.Name,
)
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
//nolint:testpackage // white-box test of unexported isValidSecretName
package vault
import "testing"
func TestIsValidSecretNameUppercase(t *testing.T) {
t.Parallel()
tests := []struct {
name string
valid bool
}{
// Lowercase (existing behavior)
{"valid-name", true},
{"valid.name", true},
{"valid_name", true},
{"valid/path/name", true},
{"123valid", true},
// Uppercase (new behavior - issue #2)
{"Valid-Upper-Name", true},
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
{"MixedCase/Path/Name", true},
{"ALLUPPERCASE", true},
{"ABC123", true},
// Still invalid
{"", false},
{"invalid name", false},
{"invalid@name", false},
{".dotstart", false},
{"/leading-slash", false},
{"trailing-slash/", false},
{"double//slash", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := isValidSecretName(tt.name)
if result != tt.valid {
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
}
})
}
}
+421
View File
@@ -0,0 +1,421 @@
// Vault-Level Version Operation Tests
//
// Integration tests for vault-level version operations:
//
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
// version structure
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
// force flag
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
// version
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
// across versions
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
// taken after AddSecret unlocks the vault
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
// - TestUpdateVersionMetadata: Tests metadata update functionality
//
// Version Management:
// - List versions in reverse chronological order
// - Promote any version to current
// - Promotion doesn't modify timestamps
// - Metadata remains encrypted and intact
//nolint:testpackage // white-box test of unexported updateVersionMetadata
package vault
import (
"path/filepath"
"testing"
"time"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"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.
//
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon about"
// Shared fixtures for white-box tests in this package.
const (
testStateDir = "/test/state"
testSecretPath = "test/secret"
)
// Helper function to add a secret to vault with proper buffer protection
func addTestSecretToVault(
t *testing.T, vault *Vault, name string, value []byte, force bool,
) {
t.Helper()
buffer := memguard.NewBufferFromBytes(value)
defer buffer.Destroy()
err := vault.AddSecret(name, buffer, force)
require.NoError(t, err)
}
// Helper function to create a vault named "test" with its long-term key set
// up and unlocked
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper()
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Unlock the vault with the derived key
vault.Unlock(ltIdentity)
return vault
}
func TestVaultAddSecretCreatesVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Add a secret
secretValue := []byte("initial-value")
expectedValue := make([]byte, len(secretValue))
copy(expectedValue, secretValue)
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
// Check that version directory was created
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versionsDir := secretDir + "/versions"
// Should have one version
entries, err := afero.ReadDir(fs, versionsDir)
require.NoError(t, err)
assert.Len(t, entries, 1)
// Should have current symlink
currentPath := secretDir + "/current"
exists, err := afero.Exists(fs, currentPath)
require.NoError(t, err)
assert.True(t, exists)
// Get the secret value
retrievedValue, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
defer retrievedValue.Destroy()
assert.Equal(t, expectedValue, retrievedValue.Bytes())
}
func TestVaultAddSecretMultipleVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Add first version
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
// Try to add again without force - should fail
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
defer failBuffer.Destroy()
err := vault.AddSecret(testSecretPath, failBuffer, false)
require.ErrorIs(t, err, ErrSecretExists)
// Add with force - should create new version
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
// Check that we have two versions
vaultDir, _ := vault.GetDirectory()
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
entries, err := afero.ReadDir(fs, versionsDir)
require.NoError(t, err)
assert.Len(t, entries, 2)
// Current value should be version-2
value, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
}
func TestVaultGetSecretVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Add multiple versions
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
// Small delay to ensure different version names
time.Sleep(10 * time.Millisecond)
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
// Get versions list
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Get specific version (first one)
firstVersion := versions[1] // Last in list is first created
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
require.NoError(t, err)
defer first.Destroy()
assert.Equal(t, []byte("version-1"), first.Bytes())
// Get specific version (second one)
secondVersion := versions[0] // First in list is most recent
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
require.NoError(t, err)
defer second.Destroy()
assert.Equal(t, []byte("version-2"), second.Bytes())
// An empty version is not one of the versions; GetSecret gets the
// current one
_, err = vault.GetSecretVersion(testSecretPath, "")
require.ErrorIs(t, err, ErrVersionNotFound)
}
func TestVaultVersionTimestamps(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Get long-term key
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
// Add first version
beforeFirst := time.Now()
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
defer v1Buffer.Destroy()
err = vault.AddSecret(testSecretPath, v1Buffer, false)
require.NoError(t, err)
afterFirst := time.Now()
// Get first version metadata
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 1)
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
// Check first version timestamps
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
assert.True(t,
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
assert.True(t,
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
assert.NotNil(t, firstVersion.Metadata.NotBefore)
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
// Add second version
time.Sleep(10 * time.Millisecond)
beforeSecond := time.Now()
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
afterSecond := time.Now()
// Get updated versions
versions, err = secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Reload first version metadata (should have notAfter now)
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, firstVersion.Metadata.NotAfter)
assert.True(t,
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
assert.True(t,
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
// Check second version timestamps
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
err = secondVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, secondVersion.Metadata.NotBefore)
assert.True(t,
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
assert.True(t,
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
// longTermKeyReadFs records when longterm.age, the last file an unlock
// reads, was last opened.
type longTermKeyReadFs struct {
afero.Fs
readAt time.Time
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == "longterm.age" {
f.readAt = time.Now()
}
return f.Fs.Open(name)
}
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
// unlock the vault to replace a version, the new version's notBefore and the
// replaced version's notAfter are taken after the unlock, which can wait for
// the user to type the passphrase.
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
t.Parallel()
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphrase.Destroy()
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
require.NoError(t, err)
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
// A new instance is locked; AddSecret unlocks it with the passphrase
vault := NewVault(fs, testStateDir, "test")
vault.SetUnlockPassphrase(passphrase)
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
}
func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Add a secret
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
// Try to get non-existent version
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
require.ErrorIs(t, err, ErrVersionNotFound)
}
func TestUpdateVersionMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs)
// Get long-term key
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
// Create a version manually to test updateVersionMetadata
versionName := "20231215.001"
version := secret.NewVersion(vault, testSecretPath, versionName)
// Set initial metadata
now := time.Now()
epochPlusOne := time.Unix(1, 0)
version.Metadata.NotBefore = &epochPlusOne
version.Metadata.NotAfter = nil
// Save version
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
defer testBuffer.Destroy()
err = version.Save(testBuffer)
require.NoError(t, err)
// Update metadata
version.Metadata.NotAfter = &now
err = updateVersionMetadata(fs, version, ltIdentity)
require.NoError(t, err)
// Load and verify
version2 := secret.NewVersion(vault, testSecretPath, versionName)
err = version2.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, version2.Metadata.NotAfter)
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
}

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