1 Commits
Author SHA1 Message Date
sneak c0b02b3dcb Give each failure one error value (closes #113)
check / check (push) Failing after 3s
internal/cli's copies of vault.ErrSecretNotFound, ErrVaultNotFound,
ErrVersionNotFound and ErrSecretExists are removed; the commands wrap the
vault errors. errUnsupportedUnlockerType is removed for
errInvalidUnlockerType, which names the same failure. Every error of
secret.ReadPassphrase wraps ErrPassphraseNotRead, so its callers no longer
add those words. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for a
key the keyring lacks, recognised by gpg's status line. storeInKeychain
returns errNilDataBuffer. bip85's ErrPasswordTooShort and
ErrEncodedTooShort go with their unreachable checks. Tests that matched
these errors' text use errors.Is.

Model: opus-5-5
2026-10-04 22:09:27 +00:00
81 changed files with 709 additions and 887 deletions
+73 -71
View File
@@ -1,93 +1,95 @@
# IMPORTANT RULES # IMPORTANT RULES
- Claude is an inanimate tool. The spam that Claude attempts to insert into * Claude is an inanimate tool. The spam that Claude attempts to insert into
commit messages (which it erroneously refers to as "attribution") is not commit messages (which it erroneously refers to as "attribution") is not
attribution, as I am the sole author of code created using Claude. It is attribution, as I am the sole author of code created using Claude. It is
corporate advertising for Anthropic and is therefore completely unacceptable corporate advertising for Anthropic and is therefore completely
in commit messages. unacceptable in commit messages.
- Tests should always be run before committing code. No commits should be made * Tests should always be run before committing code. No commits should be
that do not pass tests. made that do not pass tests.
- Code should always be formatted before committing. Do not commit unformatted * Code should always be formatted before committing. Do not commit
code. unformatted code.
- Code should always be linted and linter errors fixed before committing. NEVER * Code should always be linted and linter errors fixed before committing.
commit code that does not pass the linter. DO NOT modify the linter config NEVER commit code that does not pass the linter. DO NOT modify the linter
unless specifically instructed. config unless specifically instructed.
- The test suite is fast and local. When running tests, NEVER run individual * The test suite is fast and local. When running tests, NEVER run
parts of the test suite, always run the whole thing by running "make test". individual parts of the test suite, always run the whole thing by running
"make test".
- Do not stop working on a task until you have reached the definition of done * Do not stop working on a task until you have reached the definition of
provided to you in the initial instruction. Don't do part or most of the work, done provided to you in the initial instruction. Don't do part or most of
do all of the work until the criteria for done are met. the work, do all of the work until the criteria for done are met.
- When you complete each task, if the tests are passing and the code is * When you complete each task, if the tests are passing and the code is
formatted and there are no linter errors, always commit and push your work. formatted and there are no linter errors, always commit and push your
Use a good commit message and don't mention any author or co-author work. Use a good commit message and don't mention any author or co-author
attribution. attribution.
- Do not create additional files in the root directory of the project without * Do not create additional files in the root directory of the project
asking permission first. Configuration files, documentation, and build files without asking permission first. Configuration files, documentation, and
are acceptable in the root, but source code and other files should be build files are acceptable in the root, but source code and other files
organized in appropriate subdirectories. should be organized in appropriate subdirectories.
- Do not use bare strings or numbers in code, especially if they appear anywhere * Do not use bare strings or numbers in code, especially if they appear
more than once. Always define a constant (usually at the top of the file) and anywhere more than once. Always define a constant (usually at the top of
give it a descriptive name, then use that constant in the code instead of the the file) and give it a descriptive name, then use that constant in the
bare string or number. code instead of the bare string or number.
- If you are fixing a bug, write a test first that reproduces the bug and fails, * If you are fixing a bug, write a test first that reproduces the bug and
and then fix the bug in the code, using the test to verify that the fix fails, and then fix the bug in the code, using the test to verify that the
worked. fix worked.
- When implementing new features, be aware of potential side-effects (such as * When implementing new features, be aware of potential side-effects (such
state files on disk, data in the database, etc.) and ensure that it is as state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests when designing an API. possible to mock or stub these side-effects in tests when designing an
API.
- When dealing with dates and times or timestamps, always use, display, and * 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 store UTC. Set the local timezone to UTC on startup. If the user needs
the time in a different timezone, store the user's timezone in a separate to see the time in a different timezone, store the user's timezone in a
field and convert the UTC time to the user's timezone when displaying it. For separate field and convert the UTC time to the user's timezone when
internal use and internal applications and administrative purposes, always displaying it. For internal use and internal applications and
display UTC. administrative purposes, always display UTC.
- When implementing programs, put the main.go in ./cmd/<program_name>/main.go * When implementing programs, put the main.go in
and put the program's code in ./internal/<program_name>/. This allows for ./cmd/<program_name>/main.go and put the program's code in
multiple programs to be implemented in the same repository without cluttering ./internal/<program_name>/. This allows for multiple programs to be
the root directory. main.go should simply import and call implemented in the same repository without cluttering the root directory.
<program_name>.CLIEntry(). The full implementation should be in main.go should simply import and call <program_name>.CLIEntry(). The
./internal/<program_name>/. full implementation should be in ./internal/<program_name>/.
- When you are instructed to make the tests pass, DO NOT delete tests, skip * 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 tests, or change the tests specifically to make them pass (unless there
bug in the test). This is cheating, and it is bad. You should only be is a bug in the test). This is cheating, and it is bad. You should only
modifying the test if it is incorrect or if the test is no longer relevant. In be modifying the test if it is incorrect or if the test is no longer
almost all cases, you should be fixing the code that is being tested, or relevant. In almost all cases, you should be fixing the code that is
updating the tests to match a refactored implementation. being tested, or updating the tests to match a refactored implementation.
- Always write a `Makefile` with the default target being `test`, and with a * 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 `fmt` target that formats the code. The `test` target should run all
the project, and the `fmt` target should format the code. `test` should also tests in the project, and the `fmt` target should format the code. `test`
have a prerequisite target `lint` that should run any linters that are should also have a prerequisite target `lint` that should run any linters
configured for the project. that are configured for the project.
- After each completed bugfix or feature, the code must be committed. Do all of * After each completed bugfix or feature, the code must be committed. Do
the pre-commit checks (test, lint, fmt) before committing, of course. After all of the pre-commit checks (test, lint, fmt) before committing, of
each commit, push to the remote. course. After each commit, push to the remote.
- Always write tests, even if they are extremely simple and just check for * 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, correct syntax (ability to compile/import). If you are writing a new
write a test for it. You don't need to target complete coverage, but you feature, write a test for it. You don't need to target complete coverage,
should at least test any new functionality you add. but you should at least test any new functionality you add.
- Always use structured logging. Log any relevant state/context with the * 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 messages (but do not log secrets). If stdout is not a terminal, output
structured logs in jsonl format. Use go's log/slog. the structured logs in jsonl format. Use go's log/slog.
- You do not need to summarize your changes in the chat after making them. * 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 Making the changes and committing them is sufficient. If anything out of
ordinary happened, please explain it, but in the normal case where you found the ordinary happened, please explain it, but in the normal case where you
and fixed the bug, or implemented the feature, there is no need for the found and fixed the bug, or implemented the feature, there is no need for
end-of-change summary. the end-of-change summary.
+2 -14
View File
@@ -1,22 +1,10 @@
# node and yarn, copied into the lint stage for prettier, which checks the
# markdown formatting: node of the version script/bootstrap pins, built on
# Debian as the lint stage's image is.
# node:22.17.0-bookworm-slim, 2025-07-08
FROM node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0 AS node
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn-v1.22.22 /opt/yarn-v1.22.22
ENV PATH="/opt/yarn-v1.22.22/bin:${PATH}"
# script/bootstrap downloads the Go modules and installs prettier
WORKDIR /src WORKDIR /src
COPY script/ script/ COPY go.mod go.sum ./
COPY go.mod go.sum package.json yarn.lock ./ RUN go mod download
RUN script/bootstrap
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps # script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
# below run again on each build, an unchanged tree included, while the # below run again on each build, an unchanged tree included, while the
+4 -9
View File
@@ -593,10 +593,8 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We development workflow, and the Makefile targets are thin shims that call them. We
provide: provide:
- `script/bootstrap` — install all dependencies (Go, Go module download, and - `script/bootstrap` — install all dependencies (Go, Go module download),
node, yarn and prettier for formatting markdown), idempotently; prettier is idempotently; golangci-lint is not installed, it runs in docker
pinned by hash in `package.json` and `yarn.lock`; golangci-lint is not
installed, it runs in docker
- `script/setup` — make a fresh clone ready for development: runs - `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by other - `script/projectname` — output the project name (`secret`); used by other
@@ -613,11 +611,8 @@ provide:
compiles; cgo is off, so the keychain unlocker's calls into the keychain compiles; cgo is off, so the keychain unlocker's calls into the keychain
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`) (`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
and the Secure Enclave bindings (`internal/macse`) are not checked and the Secure Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code with `go fmt` and every markdown file with - `script/fmt` — format all Go code (writes)
prettier (4-space tabs, `proseWrap: always`) (writes) - `script/fmt-check` — check formatting without writing
- `script/fmt-check` — check the same formatting without writing; the
`Dockerfile` lint stage runs it, so an unformatted Go or markdown file fails
the build
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and - `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
`script/fmt-check` `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
+380 -394
View File
@@ -18,450 +18,436 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 2026-10-05: The Go module path is `sneak.berlin/go/secret`, as - 2026-10-04: A failure returns the same error value whichever command hits
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret` it (https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the keeps its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
`-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`, `ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the `vault import`, `vault remove` and `version list`, `promote` and `rm` wrap
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add` the `vault` errors. `errUnsupportedUnlockerType` is removed: `secret
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects unlocker add` gives `errInvalidUnlockerType` for an unknown type, whichever
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the check rejects it. Messages are unchanged, except that `secret decrypt`
`secret` package's error for it, not an `internal/cli` copy; on macOS, the of a missing secret says "not found", as `secret get` does, not "does not
check that the system is macOS is gone, as it could never fail. exist". Every error of `secret.ReadPassphrase` wraps
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid `secret.ErrPassphraseNotRead`, which supplies the words "failed to read
mnemonic, as `init` and `vault create` do. `secret generate secret` gives passphrase" that its callers used to add themselves; so two passphrases
`errLengthTooSmall` for a length below 1 wherever it is checked, and that differ now give only "passphrases do not match", the words now follow
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a "failed to read mnemonic:" and "failed to read passphrase confirmation:",
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it. and a terminal read error no longer repeats them. A GPG key the keyring
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which does not hold gives `secret.ErrGPGKeyNotFound`, found by gpg's status line
`secret` already returned under another name. Messages are unchanged, except for "No public key"; before, the message repeated "failed to resolve GPG
that `secret decrypt` of a missing secret says "not found", as `secret get` key fingerprint" and ended in gpg's exit status. The keychain unlocker
does, not "does not exist"; `vault import` of an invalid mnemonic says returns `errNilDataBuffer` for nil data; this and its test build only on
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type: macOS with cgo and were only read. `bip85.ErrPasswordTooShort` and
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
import says always give 86 Base64 or 80 Base85 characters, the most a password length
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`. may ask for. Tests that matched these errors' text use `errors.Is`.
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`, - 2026-10-04: Tests check which error a failure returns with `errors.Is`,
which supplies the words "failed to read passphrase" that its callers used to not by matching words of its message
add themselves; so two passphrases that differ now give only "passphrases do (https://git.eeqj.de/sneak/secret/issues/49). Every exported error that
not match", the words now follow "failed to read mnemonic:" and "failed to can be returned has a test that the function returns it, and errors
read passphrase confirmation:", and a terminal read error no longer repeats wrapping a cause are checked through the wrapping. Checks that still match
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`, text, because the error has no exported value the test can name, are
found by gpg's status line for "No public key"; before, the message repeated listed on the issue.
"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, - 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 because a file the unlocker needs is missing or damaged, its keychain item
Secure Enclave key is gone, or the passphrase is wrong, the error now ends by or Secure Enclave key is gone, or the passphrase is wrong, the error now
naming the vault, saying that it still opens with its mnemonic, and that ends by naming the vault, saying that it still opens with its mnemonic,
`secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it, and that `secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC`
gives the vault a new unlocker; for a vault that is not the current one, as in set to it, gives the vault a new unlocker; for a vault that is not the
`secret move` between vaults, it says to run `secret vault select` first current one, as in `secret move` between vaults, it says to run
(https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare `secret vault select` first (https://git.eeqj.de/sneak/secret/issues/47).
cause. The advice is given only when the vault metadata records the key the Before, it ended with the bare cause. The advice is given only when the
mnemonic derives, so not for a vault created without a mnemonic, and not when vault metadata records the key the mnemonic derives, so not for a vault
the passphrase could not be read at all. `secret vault import` is not named: created without a mnemonic, and not when the passphrase could not be read
it refuses a vault that has a long-term key. `secret encrypt` and at all. `secret vault import` is not named: it refuses a vault that has a
`secret decrypt` now read the key secret through `vault.GetSecret`, as long-term key. `secret encrypt` and `secret decrypt` now read the key
`secret get` does, so they give the same advice; `Secret.GetValue`, the other secret through `vault.GetSecret`, as `secret get` does, so they give the
way to get the long-term key, is removed. When a secret's `current` file same advice; `Secret.GetValue`, the other way to get the long-term key, is
cannot be read, the error says that `secret version list` lists its versions removed. When a secret's `current` file cannot be read, the error says
and `secret version promote` makes one current. The causes stay wrapped. that `secret version list` lists its versions and `secret version promote`
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so makes one current. The causes stay wrapped.
no two unlockers of a vault share one - 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 (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, 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 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 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 selection `unlocker add` makes acted on the older one. A PGP unlocker's ID
`pgp-` and its key's fingerprint; a second PGP unlocker for a key is still was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is
refused, now by comparing the fingerprint in the other unlockers' metadata. still refused, now by comparing the fingerprint in the other unlockers'
`unlocker list` and the shell completion of `unlocker select` and metadata. `unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read from, `unlocker remove` take each ID from the directory the unlocker was read
no longer by matching metadata, so two unlockers with the same metadata are from, no longer by matching metadata, so two unlockers with the same
listed apart; an unlocker of an unknown type is listed under its directory metadata are listed apart; an unlocker of an unknown type is listed under
name, and completion now offers Secure Enclave unlockers too. The keychain and its directory name, and completion now offers Secure Enclave unlockers too.
Secure Enclave code was type-checked by `script/lint-darwin`, never run; a The keychain and Secure Enclave code was type-checked by
test on Linux lists, completes, selects and removes each of two passphrase `script/lint-darwin`, never run; a test on Linux lists, completes, selects
unlockers with the same metadata by its own ID. and removes each of two passphrase unlockers with the same metadata by its
- 2026-10-04: README's Storage Architecture, `secret version promote`, Technical own ID.
Details and Testing text matches the code - 2026-10-04: README's Storage Architecture, `secret version promote`,
(https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault` Technical Details and Testing text matches the code
are plain files holding a name, not symbolic links; a version's metadata is (https://git.eeqj.de/sneak/secret/issues/102). `current` and
the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret` `currentvault` are plain files holding a name, not symbolic links; a
in the user's configuration directory, not `~/.local/share/secret`, and holds version's metadata is the encrypted `metadata.age`; the state directory is
the `lock` file. Also corrected: the code sets up no Touch ID for the keychain `berlin.sneak.pkg.secret` in the user's configuration directory, not
or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version `~/.local/share/secret`, and holds the `lock` file. Also corrected: the
keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata code sets up no Touch ID for the keychain or Secure Enclave unlocker, and
holds no vault name. Testing lists only `make test`. the Secure Enclave only decrypts; per-version keys give no forward
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or - 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault` not at all (https://git.eeqj.de/sneak/secret/issues/105).
now takes the unlocker passphrase too, writes the vault directory with its `vault.CreateVault` now takes the unlocker passphrase too, writes the vault
metadata, long-term public key and passphrase unlocker, `longterm.age` directory with its metadata, long-term public key and passphrase unlocker,
included, into a temporary directory, renames that into `vaults.d` once it is `longterm.age` included, into a temporary directory, renames that into
complete, and only then makes the vault current. Before, either command killed `vaults.d` once it is complete, and only then makes the vault current.
after the passphrase prompt but before the unlocker was written left a vault Before, either command killed after the passphrase prompt but before the
with no unlocker, which `vault create` had already made current and which unlocker was written left a vault with no unlocker, which `vault create` had
neither command would create again. Killed part-way now, it leaves no vault, already made current and which neither command would create again. Killed
and the next command that takes the lock deletes the temporary directory; or, part-way now, it leaves no vault, and the next command that takes the lock
killed between the rename and making the vault current, a complete vault that deletes the temporary directory; or, killed between the rename and making
is not current, which `secret vault select` makes current. 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 - 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item 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). Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the `CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
Secure Enclave key, so that a wrong passphrase creates none, and deletes 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. 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, `macse.CreateKey` finds the new key's hash right after `sc_auth` creates
and fails with an error naming the key's label if it cannot; it deletes the it, and fails with an error naming the key's label if it cannot; it deletes
key again if getting its public key then fails. The Objective-C was only read, the key again if getting its public key then fails. The Objective-C was only
never compiled or run, and so was `macse_darwin.go`, which is cgo 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 `CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
among them, before it stores the item in the keychain, and deletes the item 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 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 reported along with the first error. The tests of this run only on macOS:
Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the the Secure Enclave one in a build with cgo on a Mac with a Secure Enclave,
keychain one in a build with cgo. the keychain one in a build with cgo.
- 2026-10-04: What a command killed part-way left under a `.tmp-` name - 2026-10-04: What a command killed part-way left under a `.tmp-` name
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of (https://git.eeqj.de/sneak/secret/issues/75), the temporary directories
`secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`, of `secret.TempDirFor` and the temporary files of
encrypted keys included, is deleted by the next command that takes the state `secret.WriteFileAtomic`, encrypted keys included, is deleted by the next
directory lock. Before, it stayed until deleted by hand. A command writes command that takes the state directory lock. Before, it stayed until
`finished` into the lock file just before it releases the lock; the next one deleted by hand. A command writes `finished` into the lock file just
to take the lock searches only when it does not find that, so after a command before it releases the lock; the next one to take the lock searches only
that finished nothing is searched, however many secrets and versions there when it does not find that, so after a command that finished nothing is
are. The search looks in the state directory, each vault, each secret and each searched, however many secrets and versions there are. The search looks
version, the only directories those helpers make them in. A command that only in the state directory, each vault, each secret and each version, the
reads takes no lock and deletes nothing. A failure to delete is warned about 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 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 already removed by `secret unlocker remove` given its directory name; a
now shows it. test now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer through - 2026-10-04: An age identity's private key goes into a locked buffer
`secret.IdentityToLockedBuffer` everywhere through `secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a (https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
passphrase, PGP, keychain or Secure Enclave unlocker is created, the new when a passphrase, PGP, keychain or Secure Enclave unlocker is created,
unlocker's own key, a new secret version's key, and the key `secret encrypt` the new unlocker's own key, a new secret version's key, and the key
generates. Before, each place converted the string age returns to bytes and `secret encrypt` generates. Before, each place converted the string age
left the string in ordinary memory. The function moves the string's own bytes returns to bytes and left the string in ordinary memory. The function
into the buffer, which overwrites them; the copies age makes while writing the moves the string's own bytes into the buffer, which overwrites them; the
string remain, as its comment says. The 1.0 memory-security entry below no copies age makes while writing the string remain, as its comment says.
longer lists these places, `internal/cli/crypto.go` among them, nor The 1.0 memory-security entry below no longer lists these places,
`version.go:155`, which was `internal/secret/version.go`, not `internal/cli/crypto.go` among them, nor `version.go:155`, which was
`internal/cli/version.go`. `internal/secret/version.go`, not `internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and - 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50). (`GOOS=darwin`), with cgo off
`script/check` runs it, and the `Dockerfile` lint stage runs its commands, so (https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
`script/cibuild` does too. Before, CI on Linux never compiled the files built the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and Before, CI on Linux never compiled the files built only for macOS. Compiling
both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS. cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
So the three functions that call `go-keychain` moved from `github.com/keybase/go-keychain` are cgo on macOS. So the three functions
`keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on that call `go-keychain` moved from `keychainunlocker.go` to
macOS like `macse_darwin.go`. A macOS build without cgo, which before did not `keychainunlocker_cgo.go`, built only with cgo on macOS like
compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose `macse_darwin.go`. A macOS build without cgo, which before did not compile,
errors say the keychain or Secure Enclave needs a macOS build with cgo. The gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
check covers the rest of the keychain unlocker, the Secure Enclave unlocker say the keychain or Secure Enclave needs a macOS build with cgo. The check
and the macOS-only tests other than `keychainunlocker_test.go`, whose lint 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 findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into `GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
functions of their own, and the Secure Enclave unlocker derives the long-term into functions of their own, and the Secure Enclave unlocker derives the
key from the mnemonic through the same function as the keychain unlocker long-term key from the mnemonic through the same function as the keychain
instead of a copy of it. Lines over 88 columns in the files the check cannot unlocker instead of a copy of it. Lines over 88 columns in the files the
see are wrapped. check cannot see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and - 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything `secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the (https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then opens last, and for the last the vault's secret count and that the vault then
only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
command whose stdin is not a terminal fails at once. `--force` (now also on `--force`, a command whose stdin is not a terminal fails at once. `--force`
`rm` and `version rm`) removes without asking; it replaces the old refusals to (now also on `rm` and `version rm`) removes without asking; it replaces the
remove a vault with secrets or the last unlocker of one without `--force`, old refusals to remove a vault with secrets or the last unlocker of one
which the question now covers. The checks run, and the question is asked, without `--force`, which the question now covers. The checks run, and the
before the state directory lock is taken; under the lock the checks run again, question is asked, before the state directory lock is taken; under the
and if they would ask a different question, nothing is removed. `secret rm` lock the checks run again, and if they would ask a different question,
fails when it cannot count the versions. 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 - 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a (https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond: directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave `passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
unlocker the keychain item or Secure Enclave key, which names the directory, Enclave unlocker the keychain item or Secure Enclave key, which names the
carries the time instead of the day. `secret.WriteDir` fails on a directory directory, carries the time instead of the day. `secret.WriteDir` fails on a
that exists instead of writing into it. `unlocker add passphrase` writes the directory that exists instead of writing into it. `unlocker add passphrase`
new unlocker, makes it current, and only then removes the vault's other writes the new unlocker, makes it current, and only then removes the vault's
passphrase unlockers; a crash between the last two steps leaves the old one other passphrase unlockers; a crash between the last two steps leaves the old
beside the new, and the old passphrase still opens the vault through it until one beside the new, and the old passphrase still opens the vault through it
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP, until the next `unlocker add passphrase` or an `unlocker remove` removes it.
keychain or Secure Enclave unlocker added on the same host and day as another A PGP, keychain or Secure Enclave unlocker added on the same host and day as
of its type is added beside it instead of replacing it. 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 - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
at once, so that no program the command runs, `gpg` included, inherits them unset at once, so that no program the command runs, `gpg` included,
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
the environment; the buffers are passed down: `vault.CreateVault` takes the the command reads the environment; the buffers are passed down:
mnemonic (nil for none), a `Vault` derives its long-term key from its `vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
PGP, keychain and Secure Enclave unlocker constructors take both. passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and constructors take both. `CreatePGPUnlocker` sets both on the vault it
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic `VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
into the environment. Unsetting erases nothing: the starting environment `vault create` no longer put the mnemonic into the environment. Unsetting
(`/proc/<pid>/environ`) and memory still hold the value. The README warns erases nothing: the starting environment (`/proc/<pid>/environ`) and
against both variables. memory still hold the value. The README warns against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`, - 2026-10-04: `.golangci.yml` is again the canonical file from
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs `sneak/prompts`, byte for byte
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer (https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out in place of the deprecated `gomodguard`, so the lint no longer warns,
of non-test files. Neither raised a finding in this repo. 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 - 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the (https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
vault's long-term key as adding a passphrase unlocker does, with the vault's the vault's long-term key as adding a passphrase unlocker does, with the
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic, vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
checked against the vault, or else from the current unlocker. Before, it used mnemonic, checked against the vault, or else from the current unlocker.
the keychain unlocker's helper, which on every platform but macOS always Before, it used the keychain unlocker's helper, which on every platform
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
long-term key once from the mnemonic and once from a passphrase unlocker, and key, getting the long-term key once from the mnemonic and once from a
reads a secret through the new unlocker. passphrase unlocker, and reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`, - 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
`-` and `_`, and must not be empty, `.` or `..` `.`, `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state (https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both state the rule. `vault create`, `vault import`, `vault select`,
vault names of `mv` and shell completion of a `vault:secret` argument check `vault remove`, both vault names of `mv` and shell completion of a
the name as typed with `vault.ValidateVaultName` before building any path from `vault:secret` argument check the name as typed with
it. Before, `vault import ..` wrote a long-term key and an unlocker into the `vault.ValidateVaultName` before building any path from it. Before,
state directory itself, and `vault select ..` made that the current vault. `vault import ..` wrote a long-term key and an unlocker into the state
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree directory itself, and `vault select ..` made that the current vault.
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as - 2026-10-04: `script/cibuild` runs the checks again on an unchanged
the `CHECK_EPOCH` build argument, which both the lint and the build stage of tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
the `Dockerfile` declare after their module download, so the `RUN` steps below current time as the `CHECK_EPOCH` build argument, which both the lint
the argument run again on each build while the base images and module and the build stage of the `Dockerfile` declare after their module
downloads stay cached. Before, a second run on the same tree took every check download, so the `RUN` steps below the argument run again on each
from the build cache and reported success having run nothing. 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 - 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48). directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its `secret unlocker add pgp` resolves the GPG key's fingerprint once, for
duplicate check, and passes it to `CreatePGPUnlocker` to record. its duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and `CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
encrypt everything before writing anything. All four unlocker types write and encrypt everything before writing anything. All four unlocker
their files through `secret.WriteDir`: a new unlocker is built in a temporary types write their files through `secret.WriteDir`: a new unlocker is
directory, renamed into place when complete and removed on a failure. built in a temporary directory, renamed into place when complete and
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with removed on a failure.
the warning `unlocker list` gives, an unlocker directory whose metadata file - 2026-10-04: `secret unlocker select` and `secret unlocker remove`
cannot be checked for, read or parsed, instead of failing when it sorts before skip, with the warning `unlocker list` gives, an unlocker directory
the unlocker asked for. Such a directory, or one without a metadata file, is whose metadata file cannot be checked for, read or parsed, instead of
removed by its directory name, the name the warning gives; only the directory failing when it sorts before the unlocker asked for. Such a directory,
is removed, since its type is unknown. Removing one whose metadata file is or one without a metadata file, is removed by its directory name, the
missing or corrupt never counts as removing the last unlocker. Removing one name the warning gives; only the directory is removed, since its type
whose metadata file cannot be checked for or read always does, since it may be is unknown. Removing one whose metadata file is missing or corrupt
the only working unlocker, so in a vault with secrets it needs `--force`. never counts as removing the last unlocker. Removing one whose metadata
- 2026-10-04: A failed command prints its error once, without the usage text file cannot be checked for or read always does, since it may be the
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed only working unlocker, so in a vault with secrets it needs `--force`.
for a command called wrongly: wrong number of arguments, unknown flag, bad - 2026-10-04: A failed command prints its error once, without the usage
flag value, missing required flag, or flags that break a flag group (mutually text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
exclusive, required together, one required). The root command's still printed for a command called wrongly: wrong number of arguments,
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values unknown flag, bad flag value, missing required flag, or flags that
before that hook but required flags and flag groups only after it, so the hook break a flag group (mutually exclusive, required together, one
checks those two first. Root `SilenceUsage` would have hidden usage for all of required). The root command's `PersistentPreRunE` turns usage off.
these. Cobra checks arguments and flag values before that hook but required
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it flags and flag groups only after it, so the hook checks those two
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and first. Root `SilenceUsage` would have hidden usage for all of these.
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller - 2026-10-04: `secret get` keeps the secret in locked memory until it
destroys, and `secret get` writes its bytes straight to stdout, still with no writes it out (https://git.eeqj.de/sneak/secret/issues/37):
trailing newline. Before, the value was copied into ordinary memory that `Vault.GetSecret` and `Vault.GetSecretVersion` return a
nothing wiped, and `get --version` also wrote it to the debug log. `*memguard.LockedBuffer`, which every caller destroys, and `secret get`
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets writes its bytes straight to stdout, still with no trailing newline.
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets. Before, the value was copied into ordinary memory that nothing wiped,
`make build` calls the new `script/build`, which stamps the version (`VERSION` and `get --version` also wrote it to the debug log.
from the environment, else `git describe`) and the git commit as before. - 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install` targets use the local docker daemon, or whatever `DOCKER_HOST` the
depends on `build`. The `vet` target is gone: `script/test` runs `go vet` environment sets. `make build` calls the new `script/build`, which
first. stamps the version (`VERSION` from the environment, else
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`, `git describe`) and the git commit as before. `build`, `clean`,
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's `install` and `docker-run` are in `.PHONY`; `make install` depends on
`/secret`, `*.log`, `*.test` and `settings.local.json` `build`. The `vet` target is gone: `script/test` runs `go vet` first.
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out - 2026-10-04: `.gitignore` is the org's standard file, which ignores
`node_modules`; `.git` stays in the build context for the version stamp. `.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 - 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already exists", `secret vault create NAME` when `NAME` does, with "vault NAME already
before writing anything. The check is in `vault.CreateVault`, which both exists", before writing anything. The check is in `vault.CreateVault`,
commands call while holding the state directory lock, so two creates of one which both commands call while holding the state directory lock, so two
vault at once cannot both pass the check. Before, either command replaced the creates of one vault at once cannot both pass the check. Before, either
vault's metadata, passphrase unlocker and `longterm.age`, so none of its command replaced the vault's metadata, passphrase unlocker and
secrets could be decrypted any more. Both commands now ask for the unlocker `longterm.age`, so none of its secrets could be decrypted any more. Both
passphrase before creating the vault, so one stopped at that prompt leaves no commands now ask for the unlocker passphrase before creating the vault,
vault behind. 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 - 2026-10-04: The `internal/cli` tests are back to about their time
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test before the state directory lock
that each changing command waits for the lock releases it as soon as it sees (https://git.eeqj.de/sneak/secret/issues/80). The test that each
the command waiting there, instead of after a fixed 100 ms. The two vaults changing command waits for the lock releases it as soon as it sees the
with passphrase unlockers that the path and move tests start from are made command waiting there, instead of after a fixed 100 ms. The two vaults
once and copied for each test. with passphrase unlockers that the path and move tests start from are
- 2026-10-04: `secret mv` rejects a move whose destination is the source under made once and copied for each test.
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the - 2026-10-04: `secret mv` rejects a move whose destination is the source
macOS default) or a name reached through a symbolic link, before changing under another name, such as `foo` for `Foo` on a case-insensitive
anything, with or without `--force`, within a vault and between vaults; filesystem (the macOS default) or a name reached through a symbolic
before, `--force` removed the destination and so deleted the secret. A rename link, before changing anything, with or without `--force`, within a
that changes only letter case works on a case-sensitive filesystem as before. vault and between vaults; before, `--force` removed the destination and
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`, so deleted the secret. A rename that changes only letter case works on a
where golangci-lint is a build step rebuilt on every run case-sensitive filesystem as before.
(`--no-cache-filter`), so an unchanged tree is linted too; the module download - 2026-10-04: Lint runs only in docker: `script/lint` builds
stays cached. `script/bootstrap` no longer installs golangci-lint, and the `Dockerfile.lint`, where golangci-lint is a build step rebuilt on
`Dockerfile` lint stage calls it directly instead of `make lint`. every run (`--no-cache-filter`), so an unchanged tree is linted too;
`golangci-lint config verify` is not run: it fetches its schema live over the module download stays cached. `script/bootstrap` no longer
unpinned HTTPS. installs golangci-lint, and the `Dockerfile` lint stage calls it
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer directly instead of `make lint`. `golangci-lint config verify` is not
panics: `GetID()` warns with the unlocker's directory and returns run: it fetches its schema live over unpinned HTTPS.
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose - 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
metadata file cannot be checked for, read or parsed instead of failing, so no longer panics: `GetID()` warns with the unlocker's directory and
`secret unlocker list` still lists the others; the listing's ID lookup no returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
longer warns about that directory again. unlocker whose metadata file cannot be checked for, read or parsed
- 2026-10-03: `secret mv` rejects a move whose destination is the source instead of failing, so `secret unlocker list` still lists the others;
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which the listing's ID lookup no longer warns about that directory again.
defaults to the source name) before changing anything; before, `--force` - 2026-10-03: `secret mv` rejects a move whose destination is the
removed the destination first and so deleted the secret. Every vault name source (`mv --force x x`, `mv --force work:x work:`, or an empty
given with `vault:` must be one of the existing vaults by exact name, so destination, which defaults to the source name) before changing
`work:x work/:x` is rejected instead of being taken for a move between two anything; before, `--force` removed the destination first and so
vaults. A move within a named vault no longer makes that vault the current deleted the secret. Every vault name given with `vault:` must be one
one, whether it succeeds or fails. of the existing vaults by exact name, so `work:x work/:x` is rejected
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on instead of being taken for a move between two vaults. A move within a
`lock` in the state directory; a mutex on the in-memory test filesystem), so named vault no longer makes that vault the current one, whether it
concurrent commands no longer lose versions or race on the current pointers. succeeds or fails.
Every file is written through `secret.WriteFileAtomic` (temporary file, sync, - 2026-10-03: Commands that change the state directory hold one lock
rename), so no file is ever half-written and `current`, `currentvault` and (`flock` on `lock` in the state directory; a mutex on the in-memory
`current-unlocker` never go missing. New versions, new secrets and cross-vault test filesystem), so concurrent commands no longer lose versions or
copies are built in a temporary directory and renamed into place, and removals race on the current pointers. Every file is written through
rename out of the way first, so a version or secret is never half-added and `secret.WriteFileAtomic` (temporary file, sync, rename), so no file
never half-removed. is ever half-written and `current`, `currentvault` and
- 2026-10-03: The checks run before changing a vault now stop with an error `current-unlocker` never go missing. New versions, new secrets and
naming the path and cause when they cannot read what they inspect, instead of cross-vault copies are built in a temporary directory and renamed
reading the failure as "nothing there": the duplicate check before into place, and removals rename out of the way first, so a version
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file), or secret is never half-added and never half-removed.
the secret count that guards removing the last unlocker and removing a vault, - 2026-10-03: The checks run before changing a vault now stop with an
and the existing long-term key check before `vault import`. error naming the path and cause when they cannot read what they
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a inspect, instead of reading the failure as "nothing there": the
version only if it is one of the versions `version list` lists for that duplicate check before `unlocker add pgp` (an unreadable
secret, compared as typed before any path is built (`secret.VersionExists`), `unlockers.d` or unlocker metadata file), the secret count that
and touch nothing otherwise. An empty `--version` is rejected instead of guards removing the last unlocker and removing a vault, and the
meaning the current version. Before, `secret version rm x ../../..` deleted existing long-term key check before `vault import`.
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every - 2026-10-03: `version rm`, `version promote` and `get --version`
version. accept a version only if it is one of the versions `version list`
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit lists for that secret, compared as typed before any path is built
code after its deferred `memguard.Purge()` has run, and only `main` calls (`secret.VersionExists`), and touch nothing otherwise. An empty
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every `--version` is rejected instead of meaning the current version.
buffer before exiting; when the process is in the terminal's foreground Before, `secret version rm x ../../..` deleted the whole vault,
process group it first restores the terminal settings from startup, so an `secret version rm x ..` the secret, and `.` or `""` every version.
interrupted passphrase prompt no longer leaves echo off. - 2026-10-03: Key material is wiped on every exit: `Entry()` returns
- 2026-10-03: Every command that builds a path from a secret name checks the the exit code after its deferred `memguard.Purge()` has run, and only
name first with `vault.ValidateSecretName` and touches nothing when it is `main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
invalid: `rm`, `mv` (both names, within a vault and between vaults, before handler, which wipes every buffer before exiting; when the process is
switching the current vault), `import`, `version list`/`promote`/`rm`, in the terminal's foreground process group it first restores the
`encrypt` and `decrypt`. The error and `README.md` state the naming rule. terminal settings from startup, so an interrupted passphrase prompt no
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret longer leaves echo off.
in it. - 2026-10-03: Every command that builds a path from a secret name
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory: checks the name first with `vault.ValidateSecretName` and touches
it is generated into a locked buffer, and the keychain JSON is written and nothing when it is invalid: `rm`, `mv` (both names, within a vault
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on and between vaults, before switching the current vault), `import`,
Linux) without `encoding/json` holding it; the JSON field names are unchanged. `version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case and `README.md` state the naming rule. Before, `secret rm ..`
that needs more locked memory than the process can lock, and run every case deleted the whole vault and `secret rm .` every secret in it.
under `script/cibuild`. The image stamps the `VERSION` build argument, else - 2026-10-03: The keychain unlocker's age key passphrase stays in
`git describe --tags --always`, into `Version`, and fails if `.git` is present locked memory: it is generated into a locked buffer, and the
but yields no version; `make build` stamps `git describe` too, not a fixed keychain JSON is written and read by `KeychainData` code in
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the `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. canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical - 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable list, `lll` `.golangci.yml` (all linters enabled minus the standard disable
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and image to the tagged v2.12.2 Debian digest; fixed all ~1550 new
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`, findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl` blank lines, sentinel errors for `err113`, `t.Parallel()` where
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the safe, `_test` package conversions, complexity/`dupl` helper
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and extraction) on branch `golangci-v2.12.2`. Reworked after review:
`pkg/bip85` were reshaped so every composed error message is byte-identical to the `err113` sentinels in `internal/vault`, `internal/secret`,
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list` `internal/cli` and `pkg/bip85` were reshaped so every composed
skips an unreadable `unlockers.d` entry with a warning instead of emitting a error message is byte-identical to `main`, and
fabricated fallback ID. `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` - 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27). (https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target; - 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`. `.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and - 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the `.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256. `Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection, - 2026-03-11: Secure Enclave unlocker for hardware-backed secret
plus review fixes (stub panics, derivation index, tests, README) on branch protection, plus review fixes (stub panics, derivation index, tests,
secure-enclave-unlocker. README) on branch secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out. - 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing - Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
metadata, allow uppercase secret names, fix hardcoded derivation index, missing metadata, allow uppercase secret names, fix hardcoded
validate names in GetSecretVersion against path traversal, return errors derivation index, validate names in GetSecretVersion against path
instead of panicking, add Warn() on silent anomalies. traversal, return errors instead of panicking, add Warn() on silent
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths anomalies.
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs - Memory security hardening: LockedBuffer used through encrypt/decrypt
removed. paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
- Per-secret keypair architecture, vault package refactor, versioning with bare-[]byte APIs removed.
--version, comprehensive test suite with in-memory filesystem. - 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). - Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands. - Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI). - 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
CLI).
# Future Steps # Future Steps
- Implement version-number shell completion for the second arg of - Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` (`internal/cli/version.go`; `secret version promote` and `secret version rm`
was an in-code TODO removed for godox). (`internal/cli/version.go`; was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go` - Cover mnemonic-vs-xprv identity consistency in
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox). `pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
- CI does not compile, lint or test the files built only with cgo on macOS, in-code FIXME removed for godox).
since compiling them needs Apple's SDK: - 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 `internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse` `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
run on them, so it would likely find more there than the line lengths. No never run on them, so it would likely find more there than the line
macOS test runs in CI. A macOS runner would cover all of it (asked on lengths. No macOS test runs in CI. A macOS runner would cover all of it
https://git.eeqj.de/sneak/secret/issues/50). (asked on https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in - Memory security: age writes an identity's private key out as a string in
ordinary memory, and the copies it makes on the way stay there ordinary memory, and the copies it makes on the way stay there
@@ -469,8 +455,8 @@ https://git.eeqj.de/sneak/secret/milestone/12
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Split oversized CLI functions. - Split oversized CLI functions.
- Cleanups: read statedir from environment or default instead of passing it - Cleanups: read statedir from environment or default instead of
around. passing it around.
- Enhancements: help examples, colored output, --quiet flag, name suggestions on - Enhancements: help examples, colored output, --quiet flag, name suggestions on
miss, audit logging, hardware integration tests (Keychain, GPG), naming miss, audit logging, hardware integration tests (Keychain, GPG), naming
consistency, vault export/import, batch operations, search, secret metadata consistency, vault export/import, batch operations, search, secret metadata
+1 -1
View File
@@ -4,7 +4,7 @@ package main
import ( import (
"os" "os"
"sneak.berlin/go/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
) )
func main() { func main() {
+3 -3
View File
@@ -1,4 +1,4 @@
module sneak.berlin/go/secret module git.eeqj.de/sneak/secret
go 1.24.1 go 1.24.1
@@ -10,8 +10,6 @@ require (
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/dustin/go-humanize v1.0.1
github.com/fatih/color v1.18.0
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
@@ -28,6 +26,8 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
+1 -1
View File
@@ -6,10 +6,10 @@ import (
"io" "io"
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
) )
// Instance encapsulates all CLI functionality and state // Instance encapsulates all CLI functionality and state
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
func TestCLIInstanceStateDir(t *testing.T) { func TestCLIInstanceStateDir(t *testing.T) {
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"slices" "slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// getSecretNamesCompletionFunc returns a completion function that provides // getSecretNamesCompletionFunc returns a completion function that provides
+1 -1
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
"sneak.berlin/go/secret/internal/vault"
) )
// Sentinel errors for asking the user to confirm a removal // Sentinel errors for asking the user to confirm a removal
+2 -2
View File
@@ -24,12 +24,12 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+3 -35
View File
@@ -5,18 +5,17 @@ import (
"io" "io"
"maps" "maps"
"os" "os"
"os/exec"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestCreateExistingVaultChangesNothing is a regression test for // TestCreateExistingVaultChangesNothing is a regression test for
@@ -195,37 +194,6 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
} }
} }
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
// read mnemonic: failed to read passphrase: ...". The error must wrap
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
// pinned on the built binary, whose stdin is surely not a terminal.
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
t.Parallel()
c := cli.NewCLIInstanceWithStateDir(afero.NewMemMapFs(), testStateDir)
require.ErrorIs(t, c.Init(discardCmd()), secret.ErrMnemonicNotRead)
stateDir := t.TempDir()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "init")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
"or run interactively\n", string(output))
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for // TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault // https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was // create` killed after the passphrase prompt but before the unlocker was
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"os" "os"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Sentinel errors for encrypt/decrypt operations // Sentinel errors for encrypt/decrypt operations
+2 -2
View File
@@ -10,11 +10,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry must return its exit code rather than exit, so that its deferred // Entry must return its exit code rather than exit, so that its deferred
+2 -2
View File
@@ -3,9 +3,9 @@ package cli_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/cobra" "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 // TestMissingSecretOrVaultErrors checks that a command that finds no such
+9 -6
View File
@@ -7,10 +7,10 @@ import (
"math/big" "math/big"
"os" "os"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -20,7 +20,11 @@ const (
// Sentinel errors for secret generation // Sentinel errors for secret generation
var ( var (
errLengthTooSmall = errors.New("length must be at least 1") errLengthTooSmall = errors.New("length must be at least 1")
errLengthNotPositive = errors.New("length must be positive")
errMnemonicTypeNotSupported = errors.New(
"mnemonic type not supported for secret generation, " +
"use 'secret generate mnemonic' instead")
errUnsupportedSecretType = errors.New("unsupported type") errUnsupportedSecretType = errors.New("unsupported type")
) )
@@ -144,8 +148,7 @@ func (cli *Instance) GenerateSecret(
case "alnum": case "alnum":
secretValue, err = generateRandomAlnum(length) secretValue, err = generateRandomAlnum(length)
case "mnemonic": case "mnemonic":
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)", return errMnemonicTypeNotSupported
errUnsupportedSecretType)
default: default:
return fmt.Errorf("%w: %s (supported: base58, alnum)", return fmt.Errorf("%w: %s (supported: base58, alnum)",
errUnsupportedSecretType, secretType) errUnsupportedSecretType, secretType)
@@ -201,8 +204,8 @@ func generateRandomAlnum(length int) (string, error) {
// generateRandomString generates a random string of the specified length // generateRandomString generates a random string of the specified length
// using the given character set // using the given character set
func generateRandomString(length int, charset string) (string, error) { func generateRandomString(length int, charset string) (string, error) {
if length < 1 { if length <= 0 {
return "", errLengthTooSmall return "", errLengthNotPositive
} }
result := make([]byte, length) result := make([]byte, length)
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// Version info - these are set at build time // Version info - these are set at build time
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// vaultStats accumulates statistics while walking vault directories // vaultStats accumulates statistics while walking vault directories
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"os" "os"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// errPassphraseMismatch is returned when passphrase confirmation fails // errPassphraseMismatch is returned when passphrase confirmation fails
@@ -55,11 +55,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo // Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ") mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
if err != nil { if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err) secret.Debug("Failed to read mnemonic from stdin", "error", err)
return nil, nil, err return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
} }
fmt.Fprintln(os.Stderr) // Add newline after hidden input fmt.Fprintln(os.Stderr) // Add newline after hidden input
-67
View File
@@ -1,67 +0,0 @@
//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)
}
+4 -4
View File
@@ -17,15 +17,15 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
+3 -3
View File
@@ -4,12 +4,12 @@ import (
"io" "io"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestLeftoversRemovedByNextChangingCommand is a regression test for // TestLeftoversRemovedByNextChangingCommand is a regression test for
+2 -2
View File
@@ -13,13 +13,13 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
) )
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for // TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
+3 -3
View File
@@ -9,13 +9,13 @@ import (
"sync" "sync"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+1 -1
View File
@@ -3,11 +3,11 @@ package cli
import ( import (
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"golang.org/x/term" "golang.org/x/term"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry runs the secret CLI and returns the process exit code. It wipes // Entry runs the secret CLI and returns the process exit code. It wipes
+9 -3
View File
@@ -11,11 +11,11 @@ import (
"slices" "slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -32,7 +32,9 @@ const (
// Sentinel errors for secret operations // Sentinel errors for secret operations
var ( var (
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit") errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
errSecretFileTooLarge = errors.New(
"secret file too large: exceeds 100MB limit")
errCrossVaultSourceUnqualified = errors.New( errCrossVaultSourceUnqualified = errors.New(
"source must specify vault (e.g., vault:secret) for cross-vault move") "source must specify vault (e.g., vault:secret) for cross-vault move")
errMoveOntoItself = errors.New("cannot be moved onto itself") errMoveOntoItself = errors.New("cannot be moved onto itself")
@@ -667,6 +669,10 @@ func (cli *Instance) ImportSecret(
buffers, totalSize, err := readSecretFromReader(file) buffers, totalSize, err := readSecretFromReader(file)
if err != nil { if err != nil {
if errors.Is(err, errSecretTooLarge) {
return errSecretFileTooLarge
}
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
} }
defer destroyBuffers(buffers) defer destroyBuffers(buffers)
+3 -3
View File
@@ -10,13 +10,13 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testVaultName is the vault name used by the size tests. // testVaultName is the vault name used by the size tests.
@@ -289,7 +289,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
{ {
name: "101MB file - should fail", name: "101MB file - should fail",
size: 101 * 1024 * 1024, size: 101 * 1024 * 1024,
wantErr: errSecretTooLarge, wantErr: errSecretFileTooLarge,
}, },
} }
+1 -1
View File
@@ -7,9 +7,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret // TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"os" "os"
"strings" "strings"
"sneak.berlin/go/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
) )
// ExecuteCommandInProcess executes a CLI command in-process for testing // ExecuteCommandInProcess executes a CLI command in-process for testing
+1 -1
View File
@@ -3,9 +3,9 @@ package cli_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
) )
//nolint:paralleltest // executes the CLI in-process against shared state //nolint:paralleltest // executes the CLI in-process against shared state
+6 -6
View File
@@ -19,14 +19,14 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -260,9 +260,9 @@ func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
assert.Equal(t, "Error: failed to unlock vault: "+ assert.Equal(t, "Error: failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+ "failed to get long-term key: failed to get unlocker identity: "+
"failed to read passphrase: stdin is not a terminal (piped input or "+ "failed to read passphrase: cannot read passphrase from non-terminal "+
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+ "stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE "+
"run interactively\n", string(output)) "environment variable or run interactively\n", string(output))
} }
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and // TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
+14 -2
View File
@@ -15,10 +15,10 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Unlocker type names and platform identifiers shared across the CLI // Unlocker type names and platform identifiers shared across the CLI
@@ -39,6 +39,10 @@ var (
errInvalidUnlockerType = errors.New("invalid unlocker type") errInvalidUnlockerType = errors.New("invalid unlocker type")
errKeyIDOnlyForPGP = errors.New( errKeyIDOnlyForPGP = errors.New(
"--keyid flag is only valid for PGP unlockers") "--keyid flag is only valid for PGP unlockers")
errKeychainMacOSOnly = errors.New(
"keychain unlockers are only supported on macOS")
errSecureEnclaveMacOSOnly = errors.New(
"secure enclave unlockers are only supported on macOS")
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller // errGPGKeyAlreadyUnlocker carries only the message tail; the caller
// composes "GPG key <id> is already added as an unlocker". // composes "GPG key <id> is already added as an unlocker".
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
@@ -489,6 +493,10 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault // addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error { func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errKeychainMacOSOnly
}
keychainUnlocker, err := secret.CreateKeychainUnlocker( keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
@@ -516,6 +524,10 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the // addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
// current vault // current vault
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error { func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errSecureEnclaveMacOSOnly
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker( seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has. // unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
+1 -1
View File
@@ -17,10 +17,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// newCorruptUnlockerVault returns the two-unlocker test vault with the // newCorruptUnlockerVault returns the two-unlocker test vault with the
+2 -2
View File
@@ -7,11 +7,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers // TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
+1 -1
View File
@@ -21,11 +21,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
+1 -1
View File
@@ -28,11 +28,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// usageHeading starts the usage text cobra prints after an error. // usageHeading starts the usage text cobra prints after an error.
+5 -4
View File
@@ -10,19 +10,20 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Sentinel errors for vault operations // Sentinel errors for vault operations
var ( var (
errMnemonicEmpty = errors.New("mnemonic cannot be empty") errMnemonicEmpty = errors.New("mnemonic cannot be empty")
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase") errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
errVaultHasLongTermKey = errors.New( errVaultHasLongTermKey = errors.New(
"already has a long-term key configured") "already has a long-term key configured")
errMnemonicEnvNotSet = errors.New( errMnemonicEnvNotSet = errors.New(
@@ -380,7 +381,7 @@ func (cli *Instance) vaultImportPreflight(
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonic) { if !bip39.IsMnemonicValid(mnemonic) {
return "", "", "", errInvalidMnemonicPhrase return "", "", "", errInvalidMnemonic
} }
return vaultDir, pubKeyPath, mnemonic, nil return vaultDir, pubKeyPath, mnemonic, nil
+2 -2
View File
@@ -11,10 +11,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+3 -3
View File
@@ -26,13 +26,13 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
+3 -3
View File
@@ -8,13 +8,13 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/macse"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
var errInjected = errors.New("injected failure") var errInjected = errors.New("injected failure")
+18 -37
View File
@@ -17,17 +17,16 @@ import (
var ( var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil") errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errStdinNotTerminal = errors.New( errStdinNotTerminal = errors.New(
"stdin is not a terminal (piped input or script)") "cannot read passphrase from non-terminal stdin " +
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable or run interactively")
errStderrNotTerminal = errors.New( errStderrNotTerminal = errors.New(
"stderr is not a terminal (running in non-interactive mode)") "cannot prompt for passphrase: stderr is not a terminal " +
errNothingEntered = errors.New("nothing was entered") "(running in non-interactive mode). Please set the " +
"SB_UNLOCK_PASSPHRASE environment variable")
errEmptyPassphrase = errors.New("passphrase cannot be empty") errEmptyPassphrase = errors.New("passphrase cannot be empty")
) )
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
// terminal to read the mnemonic from, reading it failed, or it was empty.
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
// EncryptToRecipient encrypts data to a recipient using age // EncryptToRecipient encrypts data to a recipient using age
// The data parameter should be a LockedBuffer for secure memory handling // The data parameter should be a LockedBuffer for secure memory handling
func EncryptToRecipient( func EncryptToRecipient(
@@ -170,58 +169,40 @@ func DecryptWithPassphrase(
// Returns a LockedBuffer containing the passphrase for secure memory handling. // Returns a LockedBuffer containing the passphrase for secure memory handling.
// Every error it returns wraps ErrPassphraseNotRead. // Every error it returns wraps ErrPassphraseNotRead.
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) { func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
}
// ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a
// passphrase. Every error it returns wraps ErrMnemonicNotRead.
func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
}
// readFromTerminal reads input from the terminal without echoing it. Every
// error it returns wraps notRead; without a terminal, the error says to set
// envVar instead.
func readFromTerminal(
prompt string, notRead error, envVar string,
) (*memguard.LockedBuffer, error) {
// Check if stdin is a terminal // Check if stdin is a terminal
if !term.IsTerminal(syscall.Stdin) { if !term.IsTerminal(syscall.Stdin) {
// Not a terminal - never read secrets from piped input // Not a terminal - never read passphrases from piped input
// for security reasons // for security reasons
return nil, fmt.Errorf( return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, errStdinNotTerminal)
"%w: %w. Please set the %s environment variable or run interactively",
notRead, errStdinNotTerminal, envVar)
} }
// stdin is a terminal, check if stderr is also a terminal for // stdin is a terminal, check if stderr is also a terminal for
// interactive prompting // interactive prompting
if !term.IsTerminal(syscall.Stderr) { if !term.IsTerminal(syscall.Stderr) {
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable", return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, errStderrNotTerminal)
notRead, errStderrNotTerminal, envVar)
} }
// Both stdin and stderr are terminals - use secure password reading // Both stdin and stderr are terminals - use secure password reading
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
input, err := term.ReadPassword(syscall.Stdin) passphrase, err := term.ReadPassword(syscall.Stdin)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %w", notRead, err) return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, err)
} }
// Print newline to stderr since ReadPassword doesn't echo // Print newline to stderr since ReadPassword doesn't echo
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
if len(input) == 0 { if len(passphrase) == 0 {
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered) return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, errEmptyPassphrase)
} }
// Create a secure buffer and copy the input // Create a secure buffer and copy the passphrase
secureBuffer := memguard.NewBufferFromBytes(input) secureBuffer := memguard.NewBufferFromBytes(passphrase)
// Clear the original input slice // Clear the original passphrase slice
for i := range input { for i := range passphrase {
input[i] = 0 passphrase[i] = 0
} }
return secureBuffer, nil return secureBuffer, nil
+1 -1
View File
@@ -4,9 +4,9 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
// TestIdentityToLockedBuffer checks that the buffer holds the identity's // TestIdentityToLockedBuffer checks that the buffer holds the identity's
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/pkg/agehd"
) )
// realVault is a minimal VaultInterface backed by a real afero filesystem, // realVault is a minimal VaultInterface backed by a real afero filesystem,
+1 -1
View File
@@ -3,7 +3,7 @@ package secret_test
import ( import (
"testing" "testing"
"sneak.berlin/go/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
) )
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) { func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
+19 -1
View File
@@ -11,12 +11,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -38,6 +39,8 @@ const (
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var ( var (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty") errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format") errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New( errUnsupportedCurrentUnlocker = errors.New(
@@ -391,6 +394,12 @@ func deriveLongTermPrivateKey(
func CreateKeychainUnlocker( func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) { ) (*KeychainUnlocker, error) {
// Check if we're on macOS
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
// Get current vault using the GetCurrentVault function from the same package // Get current vault using the GetCurrentVault function from the same package
vault, err := GetCurrentVault(fs, stateDir) vault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
@@ -546,6 +555,15 @@ func writeKeychainUnlocker(
}, nil }, nil
} }
// checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for // validateKeychainItemName validates that a keychain item name is safe for
// command execution // command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the standard BIP39 test vector mnemonic. // testMnemonic is the standard BIP39 test vector mnemonic.
+3 -3
View File
@@ -17,11 +17,11 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// pgpUnlockerType is the type of a PGP unlocker. // pgpUnlockerType is the type of a PGP unlocker.
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// The GPG key ID and fingerprint passed to CreatePGPUnlocker. // The GPG key ID and fingerprint passed to CreatePGPUnlocker.
+1 -1
View File
@@ -9,9 +9,9 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonicValue is the standard BIP39 test vector mnemonic. // testMnemonicValue is the standard BIP39 test vector mnemonic.
+6 -1
View File
@@ -12,9 +12,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/macse"
) )
const ( const (
@@ -216,6 +216,11 @@ func CreateSecureEnclaveUnlocker(
stateDir string, stateDir string,
mnemonic, passphrase *memguard.LockedBuffer, mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
vault, err := GetCurrentVault(fs, stateDir) vault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err) return nil, fmt.Errorf("failed to get current vault: %w", err)
+5 -5
View File
@@ -22,10 +22,10 @@ const (
maxVersionsPerDay = 999 maxVersionsPerDay = 999
) )
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)") var (
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
// ErrNilValueBuffer is returned when a secret's value is given as nil. errNilValueBuffer = errors.New("value buffer is nil")
var ErrNilValueBuffer = errors.New("value buffer is nil") )
// VersionMetadata contains information about a secret version // VersionMetadata contains information about a secret version
type VersionMetadata struct { type VersionMetadata struct {
@@ -138,7 +138,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// process dies part-way. // process dies part-way.
func (sv *Version) Save(value *memguard.LockedBuffer) error { func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil { if value == nil {
return ErrNilValueBuffer return errNilValueBuffer
} }
DebugWith("Saving secret version", DebugWith("Saving secret version",
+1 -1
View File
@@ -41,11 +41,11 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
+3
View File
@@ -36,6 +36,9 @@ var (
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker". // it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker") ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil")
// ErrInvalidSecretName indicates a secret name that breaks the naming // ErrInvalidSecretName indicates a secret name that breaks the naming
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty; // rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
// no leading '.' or '/', no trailing '/', no '//', no '..' path segment. // no leading '.' or '/', no trailing '/', no '//', no '..' path segment.
+3 -3
View File
@@ -4,11 +4,11 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -60,7 +60,7 @@ func TestVaultErrors(t *testing.T) {
}, vault.ErrVaultNotFound}, }, vault.ErrVaultNotFound},
{"add a nil value", func(vlt *vault.Vault) error { {"add a nil value", func(vlt *vault.Vault) error {
return vlt.AddSecret(missingName, nil, false) return vlt.AddSecret(missingName, nil, false)
}, secret.ErrNilValueBuffer}, }, vault.ErrNilValueBuffer},
{"get a missing secret", func(vlt *vault.Vault) error { {"get a missing secret", func(vlt *vault.Vault) error {
_, err := vlt.GetSecret(missingName) _, err := vlt.GetSecret(missingName)
+2 -2
View File
@@ -9,10 +9,10 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// deriveVaultIdentity derives the long-term identity for the given vault // deriveVaultIdentity derives the long-term identity for the given vault
+2 -2
View File
@@ -30,12 +30,12 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// errUnexpectedValue is returned by concurrent readers when a secret value // errUnexpectedValue is returned by concurrent readers when a secret value
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// lockFileName is the file in the state directory that LockStateDir locks. // lockFileName is the file in the state directory that LockStateDir locks.
+2 -2
View File
@@ -5,11 +5,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
+2 -2
View File
@@ -9,10 +9,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Register the GetCurrentVault function with the secret package // Register the GetCurrentVault function with the secret package
+2 -2
View File
@@ -7,9 +7,9 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Metadata is an alias for secret.VaultMetadata // Metadata is an alias for secret.VaultMetadata
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
//nolint:paralleltest // subtests share an in-memory filesystem sequentially //nolint:paralleltest // subtests share an in-memory filesystem sequentially
+1 -1
View File
@@ -3,10 +3,10 @@ package vault_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion // TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
+2 -2
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// ListSecrets returns a list of secret names in this vault // ListSecrets returns a list of secret names in this vault
@@ -130,7 +130,7 @@ func ValidateSecretName(name string) error {
// AddSecret adds a secret to this vault // AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error { func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil { if value == nil {
return secret.ErrNilValueBuffer return ErrNilValueBuffer
} }
secret.DebugWith("Adding secret to vault", secret.DebugWith("Adding secret to vault",
+2 -2
View File
@@ -27,12 +27,12 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the mnemonic used to derive the vault long-term key. // testMnemonic is the mnemonic used to derive the vault long-term key.
+1 -1
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// Unlocker metadata type strings. // Unlocker metadata type strings.
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Vault represents a secrets vault // Vault represents a secrets vault
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) { func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
+3 -3
View File
@@ -7,11 +7,11 @@ import (
"slices" "slices"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the shared BIP39 test mnemonic for tests in this package. // testMnemonic is the shared BIP39 test mnemonic for tests in this package.
-5
View File
@@ -1,5 +0,0 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+18 -35
View File
@@ -1,21 +1,14 @@
# agehd - Deterministic Age Identities from BIP85 # agehd - Deterministic Age Identities from BIP85
The `agehd` package derives deterministic X25519 age identities using BIP85 The `agehd` package derives deterministic X25519 age identities using BIP85 entropy derivation and a deterministic random number generator (DRNG). This package only supports proper BIP85 sources: BIP39 mnemonics and extended private keys (xprv).
entropy derivation and a deterministic random number generator (DRNG). This
package only supports proper BIP85 sources: BIP39 mnemonics and extended private
keys (xprv).
## Features ## Features
- **Deterministic key generation**: Same input always produces the same age - **Deterministic key generation**: Same input always produces the same age identity
identity
- **BIP85 compliance**: Uses the BIP85 standard for entropy derivation - **BIP85 compliance**: Uses the BIP85 standard for entropy derivation
- **Multiple key support**: Generate multiple keys from the same source using - **Multiple key support**: Generate multiple keys from the same source using different indices
different indices - **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private keys (xprv)
- **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private - **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid conflicts
keys (xprv)
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid
conflicts
## Derivation Path ## Derivation Path
@@ -26,7 +19,6 @@ m/83696968'/592366788'/733482323'/n'
``` ```
Where: Where:
- `83696968'` is the BIP85 root path ("bip" in ASCII) - `83696968'` is the BIP85 root path ("bip" in ASCII)
- `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff) - `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff)
- `733482323'` is the application ID (sha256("secret") & 0x7fffffff) - `733482323'` is the application ID (sha256("secret") & 0x7fffffff)
@@ -43,7 +35,7 @@ import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@@ -69,7 +61,7 @@ import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@@ -95,7 +87,7 @@ import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@@ -122,7 +114,7 @@ import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@@ -159,8 +151,7 @@ Derives a deterministic age identity from a BIP39 mnemonic and index.
#### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)` #### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from an extended private key (xprv) and Derives a deterministic age identity from an extended private key (xprv) and index.
index.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index (0, 1, 2, ...) - `n`: The derivation index (0, 1, 2, ...)
@@ -176,8 +167,7 @@ Derives 32 bytes of entropy from a BIP39 mnemonic and index using BIP85.
#### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)` #### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from an extended private key (xprv) and index using Derives 32 bytes of entropy from an extended private key (xprv) and index using BIP85.
BIP85.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index - `n`: The derivation index
@@ -192,27 +182,20 @@ Converts 32 bytes of entropy into an age X25519 identity.
## Implementation Details ## Implementation Details
1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source
64 bytes of entropy from the input source 2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy
2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 3. **Key Generation**: 32 bytes are read from the DRNG to generate the age private key
is seeded with the 64-byte entropy 4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for X25519
3. **Key Generation**: 32 bytes are read from the DRNG to generate the age 5. **Bech32 Encoding**: The key is encoded using Bech32 with the "age-secret-key-" prefix
private key
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for
X25519
5. **Bech32 Encoding**: The key is encoded using Bech32 with the
"age-secret-key-" prefix
## Security Considerations ## Security Considerations
- The same mnemonic/xprv and index will always produce the same identity - The same mnemonic/xprv and index will always produce the same identity
- Different indices produce cryptographically independent identities - Different indices produce cryptographically independent identities
- The vendor/application scoping prevents conflicts with other BIP85 - The vendor/application scoping prevents conflicts with other BIP85 applications
applications
- The DRNG ensures high-quality randomness for key generation - The DRNG ensures high-quality randomness for key generation
- Private keys are properly clamped for X25519 usage - Private keys are properly clamped for X25519 usage
- Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary - Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary passphrases
passphrases
## Testing ## Testing
+1 -1
View File
@@ -14,11 +14,11 @@ import (
"strings" "strings"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
) )
const ( const (
+11 -18
View File
@@ -1,15 +1,10 @@
# BIP85 - Deterministic Entropy From BIP32 Keychains # BIP85 - Deterministic Entropy From BIP32 Keychains
This package implements This package implements [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which allows for deterministic derivation of entropy from a BIP32 master key. This enables a single seed to generate multiple wallet keys, mnemonics, and random values in a fully deterministic way.
[BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which
allows for deterministic derivation of entropy from a BIP32 master key. This
enables a single seed to generate multiple wallet keys, mnemonics, and random
values in a fully deterministic way.
## Overview ## Overview
BIP85 enables a variety of use cases: BIP85 enables a variety of use cases:
- Generate multiple BIP39 mnemonic seeds from a single master key - Generate multiple BIP39 mnemonic seeds from a single master key
- Derive Bitcoin HD wallet seeds (WIF format) - Derive Bitcoin HD wallet seeds (WIF format)
- Create extended private keys (XPRV) - Create extended private keys (XPRV)
@@ -22,8 +17,8 @@ BIP85 enables a variety of use cases:
```go ```go
import ( import (
"fmt" "fmt"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"sneak.berlin/go/secret/pkg/bip85"
) )
// Parse an existing master key // Parse an existing master key
@@ -119,16 +114,15 @@ m/83696968'/{app}'/{parameters}
``` ```
Where: Where:
- `83696968'` is the BIP85 root path (BIP in ASCII) - `83696968'` is the BIP85 root path (BIP in ASCII)
- `{app}'` is the application number: - `{app}'` is the application number:
- `39'` for BIP39 mnemonics - `39'` for BIP39 mnemonics
- `2'` for HD-WIF keys - `2'` for HD-WIF keys
- `32'` for XPRV - `32'` for XPRV
- `128169'` for HEX data - `128169'` for HEX data
- `707764'` for Base64 passwords - `707764'` for Base64 passwords
- `707785'` for Base85 passwords - `707785'` for Base85 passwords
- `828365'` for RSA keys - `828365'` for RSA keys
- `{parameters}` are application-specific parameters - `{parameters}` are application-specific parameters
## Test Vectors ## Test Vectors
@@ -141,13 +135,12 @@ This implementation passes all the test vectors from the BIP85 specification:
- XPRV - XPRV
- SHAKE256 DRNG output - SHAKE256 DRNG output
The implementation is also compatible with the Python reference implementation's The implementation is also compatible with the Python reference implementation's test vectors for the DRNG functionality.
test vectors for the DRNG functionality.
Run the tests with verbose output to see the test vectors and results: Run the tests with verbose output to see the test vectors and results:
``` ```
go test -v sneak.berlin/go/secret/pkg/bip85 go test -v git.eeqj.de/sneak/secret/pkg/bip85
``` ```
## References ## References
+1 -1
View File
@@ -9,9 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
) )
const ( const (
+3 -4
View File
@@ -131,10 +131,9 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# ---- JS / docs repos ---- # ---- JS / docs repos ----
# prettier, pinned in package.json and yarn.lock, formats the markdown # ensure_node
ensure_node # ensure_yarn
ensure_yarn # install_js_deps
install_js_deps
# ---- Go repos ---- # ---- Go repos ----
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
+1 -1
View File
@@ -17,7 +17,7 @@ main() {
echo dev)" echo dev)"
fi fi
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
pkg=sneak.berlin/go/secret/internal/cli pkg=git.eeqj.de/sneak/secret/internal/cli
# Build the file, not the package `./cmd/secret`: a package build # Build the file, not the package `./cmd/secret`: a package build
# also stamps git status into the binary and fails where git cannot # also stamps git status into the binary and fails where git cannot
# read the checkout, instead of falling back to `dev`/`unknown`. # read the checkout, instead of falling back to `dev`/`unknown`.
+1 -22
View File
@@ -1,33 +1,12 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes): Go with go fmt, markdown with # script/fmt: format all files (writes).
# prettier.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
go fmt ./... go fmt ./...
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
-20
View File
@@ -5,25 +5,6 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then if [ -n "$(gofmt -l .)" ]; then
@@ -31,7 +12,6 @@ main() {
gofmt -l . gofmt -l .
exit 1 exit 1
fi fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==