Files
secret/TODO.md
T
sneak aefec9d8e8
check / check (push) Failing after 3s
Rename the Go module to sneak.berlin/go/secret (closes #43)
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>.
go.mod, every import (rewritten with gofmt -r), the -X flags in
script/build and the examples in the pkg READMEs now use the new path.
go mod tidy lists go-humanize and fatih/color as direct requirements,
since internal/cli imports them.

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

Model: opus-5-5
2026-10-05 00:10:56 +00:00

478 lines
32 KiB
Markdown

# Workflow
- branch from `next`
- do the Next Step: the next open issue in the `1.0.0` milestone
- log it at the top of Completed Steps
- commit (`TODO.md` changes in the same commit as the work)
- push, and open a PR against `next`
# Status
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
authoritative.
# Next Step
Take the next open issue in the `1.0.0` milestone:
https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 2026-10-05: The Go module path is `sneak.berlin/go/secret`, as
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret`
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the
`-X` flags in `script/build` that stamp the version and commit shown by
`secret info`, and the examples in `pkg/agehd/README.md` and
`pkg/bip85/README.md`. `go mod tidy` now lists `github.com/dustin/go-humanize`
and `github.com/fatih/color`, which `internal/cli` imports, as direct
requirements. Code that imported the old path must switch to the new one.
- 2026-10-04: `make fmt` formats every markdown file with prettier (4-space
tabs, `proseWrap: always`) as well as the Go code, and `make fmt-check` checks
both, as the model scripts in the `prompts` repo do
(https://git.eeqj.de/sneak/secret/issues/110). Prettier is pinned by hash in
`package.json` and `yarn.lock`, and `script/bootstrap` installs node, yarn and
prettier. The `Dockerfile` lint stage copies node and yarn from a node image
pinned by hash and runs `script/bootstrap`, so its `make fmt-check` fails the
build on an unformatted markdown file. Every markdown file was formatted once,
wording unchanged.
- 2026-10-04: A mnemonic that cannot be read, in `secret init` and
`secret vault create`, gives an error that names the mnemonic only
(https://git.eeqj.de/sneak/secret/issues/115). It is read with
`secret.ReadMnemonic`, whose every error wraps the new
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
message said "failed to read mnemonic: failed to read passphrase:" and advised
setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
mnemonic: stdin is not a terminal (piped input or script). Please set the
SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
messages no longer repeat "cannot read passphrase" after "failed to read
passphrase:", and empty input gives "nothing was entered".
- 2026-10-04: A failure returns the same error value whichever command hits it
(https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
`secret` package's error for it, not an `internal/cli` copy; on macOS, the
check that the system is macOS is gone, as it could never fail.
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
mnemonic, as `init` and `vault create` do. `secret generate secret` gives
`errLengthTooSmall` for a length below 1 wherever it is checked, and
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
`secret` already returned under another name. Messages are unchanged, except
that `secret decrypt` of a missing secret says "not found", as `secret get`
does, not "does not exist"; `vault import` of an invalid mnemonic says
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
import says
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
which supplies the words "failed to read passphrase" that its callers used to
add themselves; so two passphrases that differ now give only "passphrases do
not match", the words now follow "failed to read mnemonic:" and "failed to
read passphrase confirmation:", and a terminal read error no longer repeats
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
found by gpg's status line for "No public key"; before, the message repeated
"failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
always give 86 Base64 or 80 Base85 characters, the most a password length may
ask for. Tests that matched these errors' text use `errors.Is`.
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
Every exported error that can be returned has a test that the function returns
it, and errors wrapping a cause are checked through the wrapping. Checks that
still match text, because the error has no exported value the test can name,
are listed on the issue.
- 2026-10-04: When a vault cannot be opened through its current unlocker,
because a file the unlocker needs is missing or damaged, its keychain item or
Secure Enclave key is gone, or the passphrase is wrong, the error now ends by
naming the vault, saying that it still opens with its mnemonic, and that
`secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it,
gives the vault a new unlocker; for a vault that is not the current one, as in
`secret move` between vaults, it says to run `secret vault select` first
(https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare
cause. The advice is given only when the vault metadata records the key the
mnemonic derives, so not for a vault created without a mnemonic, and not when
the passphrase could not be read at all. `secret vault import` is not named:
it refuses a vault that has a long-term key. `secret encrypt` and
`secret decrypt` now read the key secret through `vault.GetSecret`, as
`secret get` does, so they give the same advice; `Secret.GetValue`, the other
way to get the long-term key, is removed. When a secret's `current` file
cannot be read, the error says that `secret version list` lists its versions
and `secret version promote` makes one current. The causes stay wrapped.
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so
no two unlockers of a vault share one
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
Enclave unlocker's ID was its creation time to the minute and the host name,
and a passphrase unlocker's the time to the minute, so two created within a
minute shared an ID, and `unlocker select`, `unlocker remove` and the
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID was
`pgp-` and its key's fingerprint; a second PGP unlocker for a key is still
refused, now by comparing the fingerprint in the other unlockers' metadata.
`unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read from,
no longer by matching metadata, so two unlockers with the same metadata are
listed apart; an unlocker of an unknown type is listed under its directory
name, and completion now offers Secure Enclave unlockers too. The keychain and
Secure Enclave code was type-checked by `script/lint-darwin`, never run; a
test on Linux lists, completes, selects and removes each of two passphrase
unlockers with the same metadata by its own ID.
- 2026-10-04: README's Storage Architecture, `secret version promote`, Technical
Details and Testing text matches the code
(https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault`
are plain files holding a name, not symbolic links; a version's metadata is
the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret`
in the user's configuration directory, not `~/.local/share/secret`, and holds
the `lock` file. Also corrected: the code sets up no Touch ID for the keychain
or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version
keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata
holds no vault name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
now takes the unlocker passphrase too, writes the vault directory with its
metadata, long-term public key and passphrase unlocker, `longterm.age`
included, into a temporary directory, renames that into `vaults.d` once it is
complete, and only then makes the vault current. Before, either command killed
after the passphrase prompt but before the unlocker was written left a vault
with no unlocker, which `vault create` had already made current and which
neither command would create again. Killed part-way now, it leaves no vault,
and the next command that takes the lock deletes the temporary directory; or,
killed between the rename and making the vault current, a complete vault that
is not current, which `secret vault select` makes current.
- 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item or
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
key again if encrypting with it or writing the unlocker then fails.
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates it,
and fails with an error naming the key's label if it cannot; it deletes the
key again if getting its public key then fails. The Objective-C was only read,
never compiled or run, and so was `macse_darwin.go`, which is cgo only.
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
among them, before it stores the item in the keychain, and deletes the item
again if moving the unlocker into place then fails. A failure to delete is
reported along with the first error. The tests of this run only on macOS: the
Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the
keychain one in a build with cgo.
- 2026-10-04: What a command killed part-way left under a `.tmp-` name
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
`secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
encrypted keys included, is deleted by the next command that takes the state
directory lock. Before, it stayed until deleted by hand. A command writes
`finished` into the lock file just before it releases the lock; the next one
to take the lock searches only when it does not find that, so after a command
that finished nothing is searched, however many secrets and versions there
are. The search looks in the state directory, each vault, each secret and each
version, the only directories those helpers make them in. A command that only
reads takes no lock and deletes nothing. A failure to delete is warned about
and the command goes on. An unlocker directory with no metadata file was
already removed by `secret unlocker remove` given its directory name; a test
now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer through
`secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
unlocker's own key, a new secret version's key, and the key `secret encrypt`
generates. Before, each place converted the string age returns to bytes and
left the string in ordinary memory. The function moves the string's own bytes
into the buffer, which overwrites them; the copies age makes while writing the
string remain, as its comment says. The 1.0 memory-security entry below no
longer lists these places, `internal/cli/crypto.go` among them, nor
`version.go:155`, which was `internal/secret/version.go`, not
`internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50).
`script/check` runs it, and the `Dockerfile` lint stage runs its commands, so
`script/cibuild` does too. Before, CI on Linux never compiled the files built
only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and
both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS.
So the three functions that call `go-keychain` moved from
`keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on
macOS like `macse_darwin.go`. A macOS build without cgo, which before did not
compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose
errors say the keychain or Secure Enclave needs a macOS build with cgo. The
check covers the rest of the keychain unlocker, the Secure Enclave unlocker
and the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into
functions of their own, and the Secure Enclave unlocker derives the long-term
key from the mnemonic through the same function as the keychain unlocker
instead of a copy of it. Lines over 88 columns in the files the check cannot
see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then opens
only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
command whose stdin is not a terminal fails at once. `--force` (now also on
`rm` and `version rm`) removes without asking; it replaces the old refusals to
remove a vault with secrets or the last unlocker of one without `--force`,
which the question now covers. The checks run, and the question is asked,
before the state directory lock is taken; under the lock the checks run again,
and if they would ask a different question, nothing is removed. `secret rm`
fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
unlocker the keychain item or Secure Enclave key, which names the directory,
carries the time instead of the day. `secret.WriteDir` fails on a directory
that exists instead of writing into it. `unlocker add passphrase` writes the
new unlocker, makes it current, and only then removes the vault's other
passphrase unlockers; a crash between the last two steps leaves the old one
beside the new, and the old passphrase still opens the vault through it until
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
keychain or Secure Enclave unlocker added on the same host and day as another
of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
at once, so that no program the command runs, `gpg` included, inherits them
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
the environment; the buffers are passed down: `vault.CreateVault` takes the
mnemonic (nil for none), a `Vault` derives its long-term key from its
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
PGP, keychain and Secure Enclave unlocker constructors take both.
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
into the environment. Unsetting erases nothing: the starting environment
(`/proc/<pid>/environ`) and memory still hold the value. The README warns
against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
of non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
vault's long-term key as adding a passphrase unlocker does, with the vault's
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
checked against the vault, or else from the current unlocker. Before, it used
the keychain unlocker's helper, which on every platform but macOS always
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
long-term key once from the mnemonic and once from a passphrase unlocker, and
reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
`-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
vault names of `mv` and shell completion of a `vault:secret` argument check
the name as typed with `vault.ValidateVaultName` before building any path from
it. Before, `vault import ..` wrote a long-term key and an unlocker into the
state directory itself, and `vault select ..` made that the current vault.
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
the `CHECK_EPOCH` build argument, which both the lint and the build stage of
the `Dockerfile` declare after their module download, so the `RUN` steps below
the argument run again on each build while the base images and module
downloads stay cached. Before, a second run on the same tree took every check
from the build cache and reported success having run nothing.
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
encrypt everything before writing anything. All four unlocker types write
their files through `secret.WriteDir`: a new unlocker is built in a temporary
directory, renamed into place when complete and removed on a failure.
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
the warning `unlocker list` gives, an unlocker directory whose metadata file
cannot be checked for, read or parsed, instead of failing when it sorts before
the unlocker asked for. Such a directory, or one without a metadata file, is
removed by its directory name, the name the warning gives; only the directory
is removed, since its type is unknown. Removing one whose metadata file is
missing or corrupt never counts as removing the last unlocker. Removing one
whose metadata file cannot be checked for or read always does, since it may be
the only working unlocker, so in a vault with secrets it needs `--force`.
- 2026-10-04: A failed command prints its error once, without the usage text
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
for a command called wrongly: wrong number of arguments, unknown flag, bad
flag value, missing required flag, or flags that break a flag group (mutually
exclusive, required together, one required). The root command's
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
before that hook but required flags and flag groups only after it, so the hook
checks those two first. Root `SilenceUsage` would have hidden usage for all of
these.
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
destroys, and `secret get` writes its bytes straight to stdout, still with no
trailing newline. Before, the value was copied into ordinary memory that
nothing wiped, and `get --version` also wrote it to the debug log.
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
`make build` calls the new `script/build`, which stamps the version (`VERSION`
from the environment, else `git describe`) and the git commit as before.
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
first.
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
`/secret`, `*.log`, `*.test` and `settings.local.json`
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
`node_modules`; `.git` stays in the build context for the version stamp.
- 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already exists",
before writing anything. The check is in `vault.CreateVault`, which both
commands call while holding the state directory lock, so two creates of one
vault at once cannot both pass the check. Before, either command replaced the
vault's metadata, passphrase unlocker and `longterm.age`, so none of its
secrets could be decrypted any more. Both commands now ask for the unlocker
passphrase before creating the vault, so one stopped at that prompt leaves no
vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time before the
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
that each changing command waits for the lock releases it as soon as it sees
the command waiting there, instead of after a fixed 100 ms. The two vaults
with passphrase unlockers that the path and move tests start from are made
once and copied for each test.
- 2026-10-04: `secret mv` rejects a move whose destination is the source under
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
macOS default) or a name reached through a symbolic link, before changing
anything, with or without `--force`, within a vault and between vaults;
before, `--force` removed the destination and so deleted the secret. A rename
that changes only letter case works on a case-sensitive filesystem as before.
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
where golangci-lint is a build step rebuilt on every run
(`--no-cache-filter`), so an unchanged tree is linted too; the module download
stays cached. `script/bootstrap` no longer installs golangci-lint, and the
`Dockerfile` lint stage calls it directly instead of `make lint`.
`golangci-lint config verify` is not run: it fetches its schema live over
unpinned HTTPS.
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
panics: `GetID()` warns with the unlocker's directory and returns
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
metadata file cannot be checked for, read or parsed instead of failing, so
`secret unlocker list` still lists the others; the listing's ID lookup no
longer warns about that directory again.
- 2026-10-03: `secret mv` rejects a move whose destination is the source
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
defaults to the source name) before changing anything; before, `--force`
removed the destination first and so deleted the secret. Every vault name
given with `vault:` must be one of the existing vaults by exact name, so
`work:x work/:x` is rejected instead of being taken for a move between two
vaults. A move within a named vault no longer makes that vault the current
one, whether it succeeds or fails.
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on
`lock` in the state directory; a mutex on the in-memory test filesystem), so
concurrent commands no longer lose versions or race on the current pointers.
Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
rename), so no file is ever half-written and `current`, `currentvault` and
`current-unlocker` never go missing. New versions, new secrets and cross-vault
copies are built in a temporary directory and renamed into place, and removals
rename out of the way first, so a version or secret is never half-added and
never half-removed.
- 2026-10-03: The checks run before changing a vault now stop with an error
naming the path and cause when they cannot read what they inspect, instead of
reading the failure as "nothing there": the duplicate check before
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
the secret count that guards removing the last unlocker and removing a vault,
and the existing long-term key check before `vault import`.
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a
version only if it is one of the versions `version list` lists for that
secret, compared as typed before any path is built (`secret.VersionExists`),
and touch nothing otherwise. An empty `--version` is rejected instead of
meaning the current version. Before, `secret version rm x ../../..` deleted
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
version.
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
code after its deferred `memguard.Purge()` has run, and only `main` calls
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
buffer before exiting; when the process is in the terminal's foreground
process group it first restores the terminal settings from startup, so an
interrupted passphrase prompt no longer leaves echo off.
- 2026-10-03: Every command that builds a path from a secret name checks the
name first with `vault.ValidateSecretName` and touches nothing when it is
invalid: `rm`, `mv` (both names, within a vault and between vaults, before
switching the current vault), `import`, `version list`/`promote`/`rm`,
`encrypt` and `decrypt`. The error and `README.md` state the naming rule.
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
in it.
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
it is generated into a locked buffer, and the keychain JSON is written and
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
Linux) without `encoding/json` holding it; the JSON field names are unchanged.
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
that needs more locked memory than the process can lock, and run every case
under `script/cibuild`. The image stamps the `VERSION` build argument, else
`git describe --tags --always`, into `Version`, and fails if `.git` is present
but yields no version; `make build` stamps `git describe` too, not a fixed
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable list, `lll`
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
`pkg/bip85` were reshaped so every composed error message is byte-identical to
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
skips an unreadable `unlockers.d` entry with a warning instead of emitting a
fabricated fallback ID.
- 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
plus review fixes (stub panics, derivation index, tests, README) on branch
secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
metadata, allow uppercase secret names, fix hardcoded derivation index,
validate names in GetSecretVersion against path traversal, return errors
instead of panicking, add Warn() on silent anomalies.
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
removed.
- Per-secret keypair architecture, vault package refactor, versioning with
--version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
# Future Steps
- Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` (`internal/cli/version.go`;
was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
- CI does not compile, lint or test the files built only with cgo on macOS,
since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
run on them, so it would likely find more there than the line lengths. No
macOS test runs in CI. A macOS runner would cover all of it (asked on
https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in
ordinary memory, and the copies it makes on the way stay there
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
- Medium priority:
- Standardize error messages; stop leaking internals.
- Split oversized CLI functions.
- Cleanups: read statedir from environment or default instead of passing it
around.
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
miss, audit logging, hardware integration tests (Keychain, GPG), naming
consistency, vault export/import, batch operations, search, secret metadata
(descriptions, tags).