# 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 : 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-