Keep only open work in TODO.md (closes #198) #199
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
- branch from `next`
|
- branch from `next`
|
||||||
- do the Next Step: the next open issue in the `1.0.0` milestone
|
- 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)
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
- push, and open a PR against `next`
|
- push, and open a PR against `next`
|
||||||
|
|
||||||
@@ -15,779 +14,15 @@ pre-1.0. Open work is tracked on the issue tracker, which is authoritative.
|
|||||||
Take the next open issue in the `1.0.0` milestone:
|
Take the next open issue in the `1.0.0` milestone:
|
||||||
https://git.eeqj.de/sneak/secret/milestone/12
|
https://git.eeqj.de/sneak/secret/milestone/12
|
||||||
|
|
||||||
# Completed Steps
|
|
||||||
|
|
||||||
- 2026-10-15: The comment on `commandWait` in `internal/cli/integration_test.go`
|
|
||||||
no longer says `script/test` gives the package 30 seconds
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/195). `script/test` sets no time
|
|
||||||
limit; the test phase of the `Dockerfile` sets one with `go test -timeout`.
|
|
||||||
The comment now names that place instead of a number.
|
|
||||||
- 2026-10-15: The canonical files are re-vendored from `sneak/prompts` commit
|
|
||||||
`e3f79c4` (https://git.eeqj.de/sneak/secret/issues/129), so Go's build cache
|
|
||||||
survives between check builds and `make test` no longer compiles the standard
|
|
||||||
library and every dependency with `-race` on every run. `script/lint`,
|
|
||||||
`script/test`, `script/cibuild` and `script/docker` pass a new `CACHEBUST`
|
|
||||||
value instead of `--no-cache`, and every stage of the `Dockerfile` declares
|
|
||||||
`ARG CACHEBUST` first, so every `RUN` step still reruns on an unchanged tree.
|
|
||||||
The steps that compile mount Go's build cache, and both `go test` runs keep
|
|
||||||
`-count=1`, so no test result is reused. `script/lint` and `script/test` write
|
|
||||||
no image. The CI workflow cancels a branch's older run, stops after 20
|
|
||||||
minutes, fetches the tags and leaves no token behind. `script/bootstrap` uses
|
|
||||||
the installed node only when its major version is the pinned one.
|
|
||||||
- 2026-10-15: `make test` no longer fails on some runs in `internal/cli`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/192). `cli.Entry()` set `time.Local`
|
|
||||||
to UTC and ended by wiping every memguard buffer, and a test ran it in the
|
|
||||||
same process as the other tests. A timer left by an earlier test could read
|
|
||||||
`time.Local` as it changed, which the race detector reported, and the wipe
|
|
||||||
could destroy a buffer still in use, the likeliest cause of a crash of the
|
|
||||||
test process on another run. `main()` in `cmd/secret` now sets `time.Local`
|
|
||||||
before it calls `cli.Entry()`, and that test runs `cli.Entry()` in a process
|
|
||||||
of its own.
|
|
||||||
- 2026-10-15: `secret mv` from one vault to another unlocks both vaults before
|
|
||||||
it takes the state directory lock
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/189). It unlocked them while holding
|
|
||||||
the lock, and unlocking can ask for a passphrase, so every other command that
|
|
||||||
changes anything waited at that prompt. `moveSecretCrossVault` now checks the
|
|
||||||
move, refusing an existing destination secret without `--force`, unlocks both
|
|
||||||
vaults, and only then takes the lock. Under the lock, it checks that the
|
|
||||||
destination vault still has the long-term key it unlocked, and
|
|
||||||
`copySecretAndRemoveSource` checks the move again before it copies. A move
|
|
||||||
within a vault takes the lock in `moveSecretWithinVault`, so `MoveSecret`
|
|
||||||
takes none itself. The error for a vault created again with another key, which
|
|
||||||
`secret unlocker add` shares, now ends "nothing was changed".
|
|
||||||
- 2026-10-15: `secret init`, `secret vault create`, `secret unlocker add`, and
|
|
||||||
`secret add`, `secret import` and `secret generate secret` with `--force` ask
|
|
||||||
for the mnemonic or passphrase, or unlock the vault, before they take the
|
|
||||||
state directory lock (https://git.eeqj.de/sneak/secret/issues/152). They did
|
|
||||||
so while holding the lock, so every other command that changes anything waited
|
|
||||||
at the prompt. Under the lock, `secret unlocker add` fails if the vault was
|
|
||||||
removed, or created again with another key, and refuses a GPG key added
|
|
||||||
meanwhile; a forced add of a secret created meanwhile lets go of the lock,
|
|
||||||
gets the key and takes the lock again. `secret.CreatePGPUnlocker` takes the
|
|
||||||
unlocked vault; `SetMnemonic` and `SetUnlockPassphrase` are gone.
|
|
||||||
- 2026-10-15: A `secret mv` to another vault that copies the secret but cannot
|
|
||||||
remove the source fails (https://git.eeqj.de/sneak/secret/issues/159). It
|
|
||||||
printed a warning and the usual "Moved" line and exited 0. If the source
|
|
||||||
secret's directory is still in place, the error says the copy holds all
|
|
||||||
versions and the source remains. If the source left its vault and only the
|
|
||||||
temporary directory it was moved into could not be deleted, the error says the
|
|
||||||
secret is moved and names that directory, which the error of `RemoveDirAtomic`
|
|
||||||
now does.
|
|
||||||
- 2026-10-15: `secret encrypt` and `secret decrypt` with `--output` hold the
|
|
||||||
result in memory and open the output file only once encryption or decryption
|
|
||||||
has succeeded (https://git.eeqj.de/sneak/secret/issues/143). They emptied that
|
|
||||||
file before reading any input, so a failed command left it empty, and
|
|
||||||
encrypting a file onto itself gave the encryption of nothing. A failure to
|
|
||||||
encrypt or decrypt now leaves `--output` untouched, and `--input` can name the
|
|
||||||
same file. A failure while writing `--output`, such as a full disk, can still
|
|
||||||
leave it empty or partly written; an error from closing it is reported.
|
|
||||||
Without `--output` the result still goes to stdout as it is produced.
|
|
||||||
- 2026-10-15: A command that uses the current vault fails when `currentvault` is
|
|
||||||
empty or names no vault (https://git.eeqj.de/sneak/secret/issues/156).
|
|
||||||
`secret list` reported an empty vault with exit status 0, and `secret add`
|
|
||||||
failed on a missing directory. `ResolveVaultSymlink` now refuses an empty or
|
|
||||||
invalid name, or one with no directory under `vaults.d`, with an error that
|
|
||||||
wraps `ErrVaultNotFound`, names the file and says to run
|
|
||||||
`secret vault select <name>`. `secret vault list` and `secret info` still run.
|
|
||||||
- 2026-10-15: `secret` prints every time in UTC, whatever the machine's time
|
|
||||||
zone (https://git.eeqj.de/sneak/secret/issues/169). `secret list`,
|
|
||||||
`secret version list`, `secret unlocker list` and `secret info` printed times
|
|
||||||
in the local time zone without naming it, so one vault showed different times
|
|
||||||
on two machines. `Entry()` now sets `time.Local` to UTC before anything else,
|
|
||||||
as policy 6 in `AGENTS.md` asks, so every time the program prints or writes is
|
|
||||||
in UTC. `--json` times end in `Z`, and a new version's name takes the UTC
|
|
||||||
date. The README says so.
|
|
||||||
- 2026-10-15: `secret vault`, `secret unlocker`, `secret version` and
|
|
||||||
`secret generate` fail when given a subcommand they do not have
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/183). They printed their help and
|
|
||||||
exited 0, so a mistyped command looked like one that worked. Each now exits 1
|
|
||||||
with `unknown command "<name>" for "secret <parent>"`, and with no arguments
|
|
||||||
still prints its help. Cobra checks a command's `Args` only when it has a
|
|
||||||
`RunE`, so each gets `Args: cobra.NoArgs` and a `RunE` that prints its help;
|
|
||||||
their help now shows a `secret <parent> [flags]` usage line.
|
|
||||||
`secret vault help` fails as an unknown subcommand, while `secret help vault`
|
|
||||||
and `secret vault --help` still work.
|
|
||||||
- 2026-10-15: With `SB_UNLOCK_PASSPHRASE` set, `secret unlocker add passphrase`
|
|
||||||
asks on the terminal for the new unlocker's passphrase and uses the variable
|
|
||||||
to unlock the vault (https://git.eeqj.de/sneak/secret/issues/154). It gave the
|
|
||||||
new unlocker the passphrase in the variable without asking. With no
|
|
||||||
controlling terminal, the new unlocker takes the variable's passphrase and the
|
|
||||||
output says so. `secret.ErrNoTerminal` is exported so that the command can
|
|
||||||
tell a missing terminal from other failures to read, such as nothing typed or
|
|
||||||
a confirmation that does not match, which still fail the command.
|
|
||||||
- 2026-10-15: `secret init` and `secret vault create` stop with an error naming
|
|
||||||
the file when a vault's `vault-metadata.json` cannot be read or parsed, and
|
|
||||||
leave the state directory as it was
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/140). `GetNextDerivationIndex` passed
|
|
||||||
over such a vault, so the next vault made from the same mnemonic could get its
|
|
||||||
derivation index and so the same long-term key. A vault directory with no
|
|
||||||
metadata file is still skipped.
|
|
||||||
- 2026-10-15: The README says that `secret add` drops one trailing newline from
|
|
||||||
stdin, that `secret import` stores a file's bytes unchanged, and that
|
|
||||||
`secret get` writes the value with no newline added
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/158). It said none of this, so a PEM
|
|
||||||
key piped into `secret add` came back one byte short with no hint why. A new
|
|
||||||
test stores `a\n\n` through `add` and through `import` and checks that `add`
|
|
||||||
kept two bytes and `import` three. No program code changes.
|
|
||||||
- 2026-10-15: When gpg fails to decrypt, to encrypt or to look up a key's
|
|
||||||
fingerprint, the error ends with what gpg wrote to stderr, trimmed
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/146). It gave only gpg's exit status,
|
|
||||||
so the user could not tell why. `cmd.Output` keeps gpg's stderr in the
|
|
||||||
`*exec.ExitError` it returns, and the three errors now use it. When gpg wrote
|
|
||||||
nothing to stderr the error is as before, and when gpg wrote several lines the
|
|
||||||
error has them all.
|
|
||||||
- 2026-10-15: `secret version list` no longer shows a version without
|
|
||||||
`NOT_AFTER` as expired (https://git.eeqj.de/sneak/secret/issues/150). STATUS
|
|
||||||
was `expired` for every version other than the current one, so after a promote
|
|
||||||
the newest version, which has no `NOT_AFTER`, read `expired`. STATUS is now
|
|
||||||
`current` for the current version, `expired` for another version whose
|
|
||||||
`NOT_AFTER` has passed, and `inactive` for any other version. The README
|
|
||||||
describes the three.
|
|
||||||
- 2026-10-15: `secret unlocker list --json` for a vault with no unlockers, and
|
|
||||||
`secret vault list --json` when `vaults.d` holds no vaults, print the empty
|
|
||||||
list as `[]` instead of `null` (https://git.eeqj.de/sneak/secret/issues/148).
|
|
||||||
Both lists were nil slices, which `encoding/json` writes as `null`; each is
|
|
||||||
now made as an empty slice before it is filled. `vault.ListVaults` therefore
|
|
||||||
returns an empty slice instead of nil for an empty `vaults.d`.
|
|
||||||
- 2026-10-15: `secret add`, `secret import` and `secret generate secret` fail
|
|
||||||
with an error, not memguard's panic, when a secret is too large for the
|
|
||||||
locked-memory limit (https://git.eeqj.de/sneak/secret/issues/145). The value
|
|
||||||
was held twice, so half the usual 8 MiB limit was enough to crash them.
|
|
||||||
`secret` now raises the limit to the hard limit at startup, and each of the
|
|
||||||
three commands refuses, before its first locked allocation, a limit that
|
|
||||||
leaves fewer than 8 pages for its keys and buffers. The value is read a page
|
|
||||||
at a time into a `memguard.Stream`, which keeps it encrypted, and copied once
|
|
||||||
into locked memory after its size is checked against 100 MiB and the limit. It
|
|
||||||
leaves the stream through `Next`, not `Read`, because `Read` re-encrypts a
|
|
||||||
leftover piece and memguard hangs if that runs out of locked memory.
|
|
||||||
- 2026-10-15: Total Size in `secret info` counts every file of each version: the
|
|
||||||
value, the metadata and the version's two keys
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/147). It added up each version's
|
|
||||||
`data.age` and `metadata.age`, but a version keeps its value in `value.age`,
|
|
||||||
so no secret's value was counted. A version directory that cannot be read adds
|
|
||||||
nothing and gets a warning naming it.
|
|
||||||
- 2026-10-15: `secret vault import` is gone, with its tests and its README entry
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/139). It took only a vault without a
|
|
||||||
long-term key, and `secret init` and `secret vault create` give every vault
|
|
||||||
one, so it always failed; a mnemonic enters through those two commands. The
|
|
||||||
help and the note of `secret generate mnemonic` named `secret import`, which
|
|
||||||
imports a file as a secret, and now name `secret init` and
|
|
||||||
`secret vault create`.
|
|
||||||
- 2026-10-15: `secret version promote --help` says promote rewrites the
|
|
||||||
`current` file, not a "current symlink"
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/153). `current` is a plain file
|
|
||||||
holding the version name, as the README already said, and the comment in
|
|
||||||
`PromoteVersion` now says so too.
|
|
||||||
- 2026-10-14: `secret init` and `secret vault create` refuse a vault that
|
|
||||||
already exists before they ask for the mnemonic or the unlocker passphrase
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/151). They asked first, and
|
|
||||||
`secret init` printed "Initialized secrets manager at" before refusing. The
|
|
||||||
check `vault.CreateVault` made before writing is now
|
|
||||||
`vault.CheckNewVaultName`, which both commands call before the first prompt
|
|
||||||
and `CreateVault` still calls. It validates the name too, so
|
|
||||||
`secret vault create` with an invalid name also fails before the prompts.
|
|
||||||
`secret init` prints the "Initialized" line only once the vault is created.
|
|
||||||
- 2026-10-14: `secret get` fails when it cannot write the value
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/155). `GetSecret` and
|
|
||||||
`GetSecretWithVersion` discarded the result of writing it to stdout, so a full
|
|
||||||
disk or a failing stdout left an empty or cut-short file and exit status 0.
|
|
||||||
The write error is now returned, so the command prints it and exits 1. A
|
|
||||||
stdout the shell closed before `secret` started still exits 0, because the Go
|
|
||||||
runtime opens `/dev/null` in its place and the write there succeeds.
|
|
||||||
- 2026-10-14: `secret list` and `secret list --json` give each secret's own
|
|
||||||
creation and last-update times (https://git.eeqj.de/sneak/secret/issues/142).
|
|
||||||
They gave the time of the listing for every secret, because
|
|
||||||
`Secret.LoadMetadata` set both to the current time. It now reads file
|
|
||||||
modification times, so listing still needs no passphrase. The last update is
|
|
||||||
when the secret's `current` file was written. The creation is when its oldest
|
|
||||||
version's `value.age` was written, not that version's directory, whose time
|
|
||||||
changes when the next add rewrites its `metadata.age`. A move to another vault
|
|
||||||
gives the copied files the times of the originals. `GetSecretObject` no longer
|
|
||||||
loads the times, so `secret get` keeps its error for a missing `current` file.
|
|
||||||
- 2026-10-14: `secret unlocker add pgp` without `--keyid` uses the `default-key`
|
|
||||||
in `gpg.conf` (https://git.eeqj.de/sneak/secret/issues/141).
|
|
||||||
`getDefaultGPGKey` took that value from `gpgconf --list-options gpg` as
|
|
||||||
printed, with a leading `"` and percent-escaped, so gpg rejected the key ID.
|
|
||||||
The new `parseDefaultGPGKey` drops the `"` and percent-decodes the rest with
|
|
||||||
`url.PathUnescape`, which leaves `+` alone. A value that does not decode is an
|
|
||||||
error, not a fallback to the first secret key.
|
|
||||||
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
|
||||||
prompt, gives the error `wrong passphrase for unlocker <id>`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
|
||||||
identity matched any of the recipients", followed by advice to give the vault
|
|
||||||
a new unlocker from its mnemonic, which would have replaced an unlocker that
|
|
||||||
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
|
|
||||||
to use the mnemonic. A `priv.age` that is not an age file still gets that
|
|
||||||
advice.
|
|
||||||
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
|
|
||||||
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
|
|
||||||
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
|
|
||||||
for the passphrase at a terminal instead of failing
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
|
|
||||||
controlling terminal gets the error that names the variable to set. On SIGINT
|
|
||||||
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
|
|
||||||
since that is where the prompt turns echo off. `secret init` and
|
|
||||||
`secret vault create` no longer write a newline to stderr after the mnemonic
|
|
||||||
is typed, since the prompt ends that line on the terminal.
|
|
||||||
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
|
||||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
|
||||||
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
|
||||||
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
|
||||||
stderr when no output writer is set, so `secret vault list --json | jq`
|
|
||||||
received nothing. "No versions found" from `secret version list` moved to
|
|
||||||
stdout too. Messages such as "Created vault", and the warning after the
|
|
||||||
mnemonic, stay on stderr.
|
|
||||||
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
|
||||||
open through the unlockers it still has
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
|
||||||
naming the removed unlocker, so every command that opened the vault failed,
|
|
||||||
with advice to use the mnemonic. `secret unlocker remove` of the current
|
|
||||||
unlocker now makes the first of the vault's other unlockers that
|
|
||||||
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
|
|
||||||
current before removing it, and says so; when there is none, it also removes
|
|
||||||
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
|
|
||||||
made current. When `current-unlocker` names an unlocker that does not exist
|
|
||||||
and the vault has another that `secret unlocker select` accepts, the error
|
|
||||||
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
|
|
||||||
without the advice to use the mnemonic.
|
|
||||||
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
|
|
||||||
passphrase or mnemonic, leaves the secret as it was
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
|
|
||||||
written before the vault was unlocked to set `notAfter` on the version it
|
|
||||||
replaces, so each failed add left one more version that `secret version list`
|
|
||||||
showed. The vault is now unlocked, and that version's metadata read, before
|
|
||||||
anything is written. `generate secret --force` and `import --force` take the
|
|
||||||
same path.
|
|
||||||
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
|
||||||
same long-term key as its words separated by single spaces
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
|
||||||
text exactly as typed, so a vault made with a double or trailing space did not
|
|
||||||
open with the same words typed normally. `agehd.DeriveEntropy`, which every
|
|
||||||
use of the mnemonic goes through, now joins the words with single spaces and
|
|
||||||
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
|
|
||||||
- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code
|
|
||||||
and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were
|
|
||||||
research: neither works without macOS code signing and entitlements, which
|
|
||||||
`secret` does not have. The last commit that has them is `ef0ae90`, tagged
|
|
||||||
`v1.0.0-rc1`. `secret unlocker add` takes only `passphrase` and `pgp` on every
|
|
||||||
platform, `internal/macse` and those unlockers' files in `internal/secret` are
|
|
||||||
deleted, and `github.com/keybase/go-keychain` left `go.mod` and `go.sum`.
|
|
||||||
`secret` still builds and runs on macOS.
|
|
||||||
- 2026-10-07: The part of `github.com/tyler-smith/go-bip39` v1.1.0 that `secret`
|
|
||||||
uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it,
|
|
||||||
because its repository no longer exists
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/122). Every import moved there, and
|
|
||||||
the module left `go.mod` and `go.sum`. No derived key or mnemonic changes.
|
|
||||||
- 2026-10-07: The canonical files are re-vendored from `sneak/prompts` commit
|
|
||||||
`dd4027b` (https://git.eeqj.de/sneak/secret/issues/121), with golangci-lint
|
|
||||||
v2.14.0 in the lint phase. Lint and test are phases of the `Dockerfile`, and
|
|
||||||
`script/lint` and `script/test` each build one with `--no-cache`;
|
|
||||||
`Dockerfile.lint` and `script/lint-darwin` are gone, and the lint phase runs
|
|
||||||
`go vet` and checks the macOS build too. The tests run on the Debian Go image
|
|
||||||
with cgo and the race detector, with the policy's 90-second timeout.
|
|
||||||
`script/cibuild` bootstraps, runs `script/check` and builds the image;
|
|
||||||
`CHECK_EPOCH` and the memlock ulimit are gone. `--no-cache` starts Go's build
|
|
||||||
cache mount empty, so `make test` compiles everything on every run. The rules
|
|
||||||
in `CLAUDE.md` that `AGENTS.md` lacked are in `AGENTS.md`, and `CLAUDE.md` is
|
|
||||||
deleted.
|
|
||||||
- 2026-10-06: `script/test` runs the tests with the race detector, a 30-second
|
|
||||||
timeout per package and coverage, as `REPO_POLICIES.md` requires
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/32). When they fail, it reruns them
|
|
||||||
verbosely for the details and then fails anyway, so a test that fails once and
|
|
||||||
passes on the retry no longer gives a green build. `go vet` still runs first,
|
|
||||||
and every `go test` keeps `-count=1`. The tests that gave the `secret` binary
|
|
||||||
a minute now give it 10 seconds, and the PGP unlocker test's 30-second timer
|
|
||||||
is 10 seconds, so a test that hangs fails with its own message before the
|
|
||||||
package's 30-second timeout ends every test in it.
|
|
||||||
- 2026-10-06: `make test` in `script/cibuild` no longer compiles the standard
|
|
||||||
library and every dependency from nothing on every build
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/124). The `Dockerfile` runs it and
|
|
||||||
`make build` with Go's build cache in a BuildKit cache mount, which docker
|
|
||||||
keeps between builds, so each compiles only what changed since the last build.
|
|
||||||
The mount has an id of its own, so other repositories' builds do not share it.
|
|
||||||
`script/test` passes `-count=1`, so every test runs on every build and no
|
|
||||||
result comes from Go's test cache. A build with an empty cache, such as the
|
|
||||||
first after docker's build cache is cleared, compiles everything in
|
|
||||||
`make test` as before.
|
|
||||||
- 2026-10-06: `TestRemoveIgnoresTerminalOnStdout` and
|
|
||||||
`TestRemoveAsksAtTerminalOnStdin` no longer wait until Go's test timeout
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/126). On Linux, `pty.Open` of
|
|
||||||
`github.com/creack/pty` v1.1.24 passed the address of a local variable to the
|
|
||||||
`ioctl` system call as a plain number, through a function call; when Go moved
|
|
||||||
the goroutine's stack in between, the kernel wrote the terminal's number to
|
|
||||||
the old place, and `pty.Open` opened `/dev/pts/0` instead of the terminal it
|
|
||||||
had created. `secret rm` then wrote to that other terminal, and the test read
|
|
||||||
a terminal no program had open, which never ends. `go.mod` now requires the
|
|
||||||
commit on that library's main branch that passes a pointer instead; no release
|
|
||||||
has it yet. Both tests stop reading the terminal when their one-minute context
|
|
||||||
ends and fail saying so.
|
|
||||||
- 2026-10-06: The tests run quickly with the race detector on
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/120). Most of their time went to
|
|
||||||
deriving keys from passphrases with scrypt, which is slow on purpose. The new
|
|
||||||
`secret.ScryptWorkFactor`, when not zero, replaces age's scrypt work factor
|
|
||||||
when a passphrase encrypts; the tests of `internal/secret`, `internal/vault`
|
|
||||||
and `internal/cli` set it to 1 before any test runs, and the program never
|
|
||||||
sets it; `TestGetCommandOutputsToStdout` checks that the passphrase unlocker
|
|
||||||
the built binary's `secret init` writes names age's work factor, 18.
|
|
||||||
`TestRemovalAsksWithoutHoldingLock` and `TestFailedCommandReleasesLock` no
|
|
||||||
longer run in parallel with other tests: each waits at most 10 seconds for the
|
|
||||||
in-memory lock that every test in the package shares, and other tests'
|
|
||||||
commands held it longer. `TestConcurrentAddsKeepEveryVersion`, which times
|
|
||||||
nothing, and `TestGetCommandOutputsToStdout`, which no longer sets an
|
|
||||||
environment variable its commands do not read, now run in parallel. The
|
|
||||||
`script/cibuild` comment no longer says that tests are skipped without its
|
|
||||||
memlock ulimit.
|
|
||||||
- 2026-10-05: No test stores a secret larger than 1 MiB
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/52). The size tests for `secret add`,
|
|
||||||
`secret import` and the stdin buffer no longer try 2 MB, 10 MB, 99 MB, 100 MB
|
|
||||||
or 101 MB secrets, and nothing tests that a secret over the 100 MB limit is
|
|
||||||
rejected; the limit itself is unchanged. With nothing large left, the size
|
|
||||||
tests no longer skip a case for want of locked memory.
|
|
||||||
- 2026-10-05: The Go module path is `sneak.berlin/go/secret`, as
|
|
||||||
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the
|
|
||||||
`-X` flags in `script/build` that stamp the version and commit shown by
|
|
||||||
`secret info`, and the examples in `pkg/agehd/README.md` and
|
|
||||||
`pkg/bip85/README.md`. `go mod tidy` now lists `github.com/dustin/go-humanize`
|
|
||||||
and `github.com/fatih/color`, which `internal/cli` imports, as direct
|
|
||||||
requirements. Code that imported the old path must switch to the new one.
|
|
||||||
- 2026-10-04: `make fmt` formats every markdown file with prettier (4-space
|
|
||||||
tabs, `proseWrap: always`) as well as the Go code, and `make fmt-check` checks
|
|
||||||
both, as the model scripts in the `prompts` repo do
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/110). Prettier is pinned by hash in
|
|
||||||
`package.json` and `yarn.lock`, and `script/bootstrap` installs node, yarn and
|
|
||||||
prettier. The `Dockerfile` lint stage copies node and yarn from a node image
|
|
||||||
pinned by hash and runs `script/bootstrap`, so its `make fmt-check` fails the
|
|
||||||
build on an unformatted markdown file. Every markdown file was formatted once,
|
|
||||||
wording unchanged.
|
|
||||||
- 2026-10-04: A mnemonic that cannot be read, in `secret init` and
|
|
||||||
`secret vault create`, gives an error that names the mnemonic only
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/115). It is read with
|
|
||||||
`secret.ReadMnemonic`, whose every error wraps the new
|
|
||||||
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
|
|
||||||
message said "failed to read mnemonic: failed to read passphrase:" and advised
|
|
||||||
setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
|
|
||||||
mnemonic: stdin is not a terminal (piped input or script). Please set the
|
|
||||||
SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
|
|
||||||
messages no longer repeat "cannot read passphrase" after "failed to read
|
|
||||||
passphrase:", and empty input gives "nothing was entered".
|
|
||||||
- 2026-10-04: A failure returns the same error value whichever command hits it
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
|
|
||||||
its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
|
|
||||||
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
|
|
||||||
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
|
|
||||||
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
|
|
||||||
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
|
|
||||||
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
|
|
||||||
`secret` package's error for it, not an `internal/cli` copy; on macOS, the
|
|
||||||
check that the system is macOS is gone, as it could never fail.
|
|
||||||
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
|
|
||||||
mnemonic, as `init` and `vault create` do. `secret generate secret` gives
|
|
||||||
`errLengthTooSmall` for a length below 1 wherever it is checked, and
|
|
||||||
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
|
|
||||||
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
|
|
||||||
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
|
|
||||||
`secret` already returned under another name. Messages are unchanged, except
|
|
||||||
that `secret decrypt` of a missing secret says "not found", as `secret get`
|
|
||||||
does, not "does not exist"; `vault import` of an invalid mnemonic says
|
|
||||||
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
|
|
||||||
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
|
|
||||||
import says
|
|
||||||
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
|
|
||||||
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
|
|
||||||
which supplies the words "failed to read passphrase" that its callers used to
|
|
||||||
add themselves; so two passphrases that differ now give only "passphrases do
|
|
||||||
not match", the words now follow "failed to read mnemonic:" and "failed to
|
|
||||||
read passphrase confirmation:", and a terminal read error no longer repeats
|
|
||||||
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
|
|
||||||
found by gpg's status line for "No public key"; before, the message repeated
|
|
||||||
"failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
|
|
||||||
keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
|
|
||||||
build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
|
|
||||||
and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
|
|
||||||
always give 86 Base64 or 80 Base85 characters, the most a password length may
|
|
||||||
ask for. Tests that matched these errors' text use `errors.Is`.
|
|
||||||
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
|
|
||||||
matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
|
|
||||||
Every exported error that can be returned has a test that the function returns
|
|
||||||
it, and errors wrapping a cause are checked through the wrapping. Checks that
|
|
||||||
still match text, because the error has no exported value the test can name,
|
|
||||||
are listed on the issue.
|
|
||||||
- 2026-10-04: When a vault cannot be opened through its current unlocker,
|
|
||||||
because a file the unlocker needs is missing or damaged, its keychain item or
|
|
||||||
Secure Enclave key is gone, or the passphrase is wrong, the error now ends by
|
|
||||||
naming the vault, saying that it still opens with its mnemonic, and that
|
|
||||||
`secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it,
|
|
||||||
gives the vault a new unlocker; for a vault that is not the current one, as in
|
|
||||||
`secret move` between vaults, it says to run `secret vault select` first
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare
|
|
||||||
cause. The advice is given only when the vault metadata records the key the
|
|
||||||
mnemonic derives, so not for a vault created without a mnemonic, and not when
|
|
||||||
the passphrase could not be read at all. `secret vault import` is not named:
|
|
||||||
it refuses a vault that has a long-term key. `secret encrypt` and
|
|
||||||
`secret decrypt` now read the key secret through `vault.GetSecret`, as
|
|
||||||
`secret get` does, so they give the same advice; `Secret.GetValue`, the other
|
|
||||||
way to get the long-term key, is removed. When a secret's `current` file
|
|
||||||
cannot be read, the error says that `secret version list` lists its versions
|
|
||||||
and `secret version promote` makes one current. The causes stay wrapped.
|
|
||||||
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so
|
|
||||||
no two unlockers of a vault share one
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
|
|
||||||
Enclave unlocker's ID was its creation time to the minute and the host name,
|
|
||||||
and a passphrase unlocker's the time to the minute, so two created within a
|
|
||||||
minute shared an ID, and `unlocker select`, `unlocker remove` and the
|
|
||||||
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID was
|
|
||||||
`pgp-` and its key's fingerprint; a second PGP unlocker for a key is still
|
|
||||||
refused, now by comparing the fingerprint in the other unlockers' metadata.
|
|
||||||
`unlocker list` and the shell completion of `unlocker select` and
|
|
||||||
`unlocker remove` take each ID from the directory the unlocker was read from,
|
|
||||||
no longer by matching metadata, so two unlockers with the same metadata are
|
|
||||||
listed apart; an unlocker of an unknown type is listed under its directory
|
|
||||||
name, and completion now offers Secure Enclave unlockers too. The keychain and
|
|
||||||
Secure Enclave code was type-checked by `script/lint-darwin`, never run; a
|
|
||||||
test on Linux lists, completes, selects and removes each of two passphrase
|
|
||||||
unlockers with the same metadata by its own ID.
|
|
||||||
- 2026-10-04: README's Storage Architecture, `secret version promote`, Technical
|
|
||||||
Details and Testing text matches the code
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault`
|
|
||||||
are plain files holding a name, not symbolic links; a version's metadata is
|
|
||||||
the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret`
|
|
||||||
in the user's configuration directory, not `~/.local/share/secret`, and holds
|
|
||||||
the `lock` file. Also corrected: the code sets up no Touch ID for the keychain
|
|
||||||
or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version
|
|
||||||
keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata
|
|
||||||
holds no vault name. Testing lists only `make test`.
|
|
||||||
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
|
|
||||||
not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
|
|
||||||
now takes the unlocker passphrase too, writes the vault directory with its
|
|
||||||
metadata, long-term public key and passphrase unlocker, `longterm.age`
|
|
||||||
included, into a temporary directory, renames that into `vaults.d` once it is
|
|
||||||
complete, and only then makes the vault current. Before, either command killed
|
|
||||||
after the passphrase prompt but before the unlocker was written left a vault
|
|
||||||
with no unlocker, which `vault create` had already made current and which
|
|
||||||
neither command would create again. Killed part-way now, it leaves no vault,
|
|
||||||
and the next command that takes the lock deletes the temporary directory; or,
|
|
||||||
killed between the rename and making the vault current, a complete vault that
|
|
||||||
is not current, which `secret vault select` makes current.
|
|
||||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
|
||||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
|
||||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
|
||||||
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
|
||||||
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
|
|
||||||
key again if encrypting with it or writing the unlocker then fails.
|
|
||||||
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates it,
|
|
||||||
and fails with an error naming the key's label if it cannot; it deletes the
|
|
||||||
key again if getting its public key then fails. The Objective-C was only read,
|
|
||||||
never compiled or run, and so was `macse_darwin.go`, which is cgo only.
|
|
||||||
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
|
|
||||||
among them, before it stores the item in the keychain, and deletes the item
|
|
||||||
again if moving the unlocker into place then fails. A failure to delete is
|
|
||||||
reported along with the first error. The tests of this run only on macOS: the
|
|
||||||
Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the
|
|
||||||
keychain one in a build with cgo.
|
|
||||||
- 2026-10-04: What a command killed part-way left under a `.tmp-` name
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
|
|
||||||
`secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
|
|
||||||
encrypted keys included, is deleted by the next command that takes the state
|
|
||||||
directory lock. Before, it stayed until deleted by hand. A command writes
|
|
||||||
`finished` into the lock file just before it releases the lock; the next one
|
|
||||||
to take the lock searches only when it does not find that, so after a command
|
|
||||||
that finished nothing is searched, however many secrets and versions there
|
|
||||||
are. The search looks in the state directory, each vault, each secret and each
|
|
||||||
version, the only directories those helpers make them in. A command that only
|
|
||||||
reads takes no lock and deletes nothing. A failure to delete is warned about
|
|
||||||
and the command goes on. An unlocker directory with no metadata file was
|
|
||||||
already removed by `secret unlocker remove` given its directory name; a test
|
|
||||||
now shows it.
|
|
||||||
- 2026-10-04: An age identity's private key goes into a locked buffer through
|
|
||||||
`secret.IdentityToLockedBuffer` everywhere
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
|
|
||||||
passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
|
|
||||||
unlocker's own key, a new secret version's key, and the key `secret encrypt`
|
|
||||||
generates. Before, each place converted the string age returns to bytes and
|
|
||||||
left the string in ordinary memory. The function moves the string's own bytes
|
|
||||||
into the buffer, which overwrites them; the copies age makes while writing the
|
|
||||||
string remain, as its comment says. The 1.0 memory-security entry below no
|
|
||||||
longer lists these places, `internal/cli/crypto.go` among them, nor
|
|
||||||
`version.go:155`, which was `internal/secret/version.go`, not
|
|
||||||
`internal/cli/version.go`.
|
|
||||||
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
|
|
||||||
`golangci-lint` in docker on the code as a macOS build compiles it
|
|
||||||
(`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50).
|
|
||||||
`script/check` runs it, and the `Dockerfile` lint stage runs its commands, so
|
|
||||||
`script/cibuild` does too. Before, CI on Linux never compiled the files built
|
|
||||||
only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and
|
|
||||||
both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS.
|
|
||||||
So the three functions that call `go-keychain` moved from
|
|
||||||
`keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on
|
|
||||||
macOS like `macse_darwin.go`. A macOS build without cgo, which before did not
|
|
||||||
compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose
|
|
||||||
errors say the keychain or Secure Enclave needs a macOS build with cgo. The
|
|
||||||
check covers the rest of the keychain unlocker, the Secure Enclave unlocker
|
|
||||||
and the macOS-only tests other than `keychainunlocker_test.go`, whose lint
|
|
||||||
findings are fixed. For the length and complexity limits, parts of
|
|
||||||
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into
|
|
||||||
functions of their own, and the Secure Enclave unlocker derives the long-term
|
|
||||||
key from the mnemonic through the same function as the keychain unlocker
|
|
||||||
instead of a copy of it. Lines over 88 columns in the files the check cannot
|
|
||||||
see are wrapped.
|
|
||||||
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
|
|
||||||
`secret unlocker remove` ask `[y/N]` before removing anything
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
|
|
||||||
secret, its vault and its version count; the version, secret and vault; the
|
|
||||||
vault and its secret count; the unlocker, its vault and whether it is the
|
|
||||||
last, and for the last the vault's secret count and that the vault then opens
|
|
||||||
only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
|
|
||||||
command whose stdin is not a terminal fails at once. `--force` (now also on
|
|
||||||
`rm` and `version rm`) removes without asking; it replaces the old refusals to
|
|
||||||
remove a vault with secrets or the last unlocker of one without `--force`,
|
|
||||||
which the question now covers. The checks run, and the question is asked,
|
|
||||||
before the state directory lock is taken; under the lock the checks run again,
|
|
||||||
and if they would ask a different question, nothing is removed. `secret rm`
|
|
||||||
fails when it cannot count the versions.
|
|
||||||
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
|
||||||
current unlocker that cannot open the vault
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
|
||||||
directory of its own, named with the time to the nanosecond:
|
|
||||||
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
|
|
||||||
unlocker the keychain item or Secure Enclave key, which names the directory,
|
|
||||||
carries the time instead of the day. `secret.WriteDir` fails on a directory
|
|
||||||
that exists instead of writing into it. `unlocker add passphrase` writes the
|
|
||||||
new unlocker, makes it current, and only then removes the vault's other
|
|
||||||
passphrase unlockers; a crash between the last two steps leaves the old one
|
|
||||||
beside the new, and the old passphrase still opens the vault through it until
|
|
||||||
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
|
|
||||||
keychain or Secure Enclave unlocker added on the same host and day as another
|
|
||||||
of its type is added beside it instead of replacing it.
|
|
||||||
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
|
|
||||||
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
|
|
||||||
at once, so that no program the command runs, `gpg` included, inherits them
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
|
|
||||||
the environment; the buffers are passed down: `vault.CreateVault` takes the
|
|
||||||
mnemonic (nil for none), a `Vault` derives its long-term key from its
|
|
||||||
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
|
|
||||||
PGP, keychain and Secure Enclave unlocker constructors take both.
|
|
||||||
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
|
|
||||||
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
|
|
||||||
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
|
|
||||||
into the environment. Unsetting erases nothing: the starting environment
|
|
||||||
(`/proc/<pid>/environ`) and memory still hold the value. The README warns
|
|
||||||
against both variables.
|
|
||||||
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
|
|
||||||
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
|
|
||||||
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
|
|
||||||
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
|
|
||||||
of non-test files. Neither raised a finding in this repo.
|
|
||||||
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
|
|
||||||
vault's long-term key as adding a passphrase unlocker does, with the vault's
|
|
||||||
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
|
|
||||||
checked against the vault, or else from the current unlocker. Before, it used
|
|
||||||
the keychain unlocker's helper, which on every platform but macOS always
|
|
||||||
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
|
|
||||||
long-term key once from the mnemonic and once from a passphrase unlocker, and
|
|
||||||
reads a secret through the new unlocker.
|
|
||||||
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
|
|
||||||
`-` and `_`, and must not be empty, `.` or `..`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
|
|
||||||
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
|
|
||||||
vault names of `mv` and shell completion of a `vault:secret` argument check
|
|
||||||
the name as typed with `vault.ValidateVaultName` before building any path from
|
|
||||||
it. Before, `vault import ..` wrote a long-term key and an unlocker into the
|
|
||||||
state directory itself, and `vault select ..` made that the current vault.
|
|
||||||
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
|
|
||||||
the `CHECK_EPOCH` build argument, which both the lint and the build stage of
|
|
||||||
the `Dockerfile` declare after their module download, so the `RUN` steps below
|
|
||||||
the argument run again on each build while the base images and module
|
|
||||||
downloads stay cached. Before, a second run on the same tree took every check
|
|
||||||
from the build cache and reported success having run nothing.
|
|
||||||
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
|
||||||
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
|
||||||
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
|
|
||||||
duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
|
||||||
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
|
|
||||||
encrypt everything before writing anything. All four unlocker types write
|
|
||||||
their files through `secret.WriteDir`: a new unlocker is built in a temporary
|
|
||||||
directory, renamed into place when complete and removed on a failure.
|
|
||||||
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
|
|
||||||
the warning `unlocker list` gives, an unlocker directory whose metadata file
|
|
||||||
cannot be checked for, read or parsed, instead of failing when it sorts before
|
|
||||||
the unlocker asked for. Such a directory, or one without a metadata file, is
|
|
||||||
removed by its directory name, the name the warning gives; only the directory
|
|
||||||
is removed, since its type is unknown. Removing one whose metadata file is
|
|
||||||
missing or corrupt never counts as removing the last unlocker. Removing one
|
|
||||||
whose metadata file cannot be checked for or read always does, since it may be
|
|
||||||
the only working unlocker, so in a vault with secrets it needs `--force`.
|
|
||||||
- 2026-10-04: A failed command prints its error once, without the usage text
|
|
||||||
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
|
|
||||||
for a command called wrongly: wrong number of arguments, unknown flag, bad
|
|
||||||
flag value, missing required flag, or flags that break a flag group (mutually
|
|
||||||
exclusive, required together, one required). The root command's
|
|
||||||
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
|
|
||||||
before that hook but required flags and flag groups only after it, so the hook
|
|
||||||
checks those two first. Root `SilenceUsage` would have hidden usage for all of
|
|
||||||
these.
|
|
||||||
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
|
|
||||||
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
|
|
||||||
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
|
|
||||||
destroys, and `secret get` writes its bytes straight to stdout, still with no
|
|
||||||
trailing newline. Before, the value was copied into ordinary memory that
|
|
||||||
nothing wiped, and `get --version` also wrote it to the debug log.
|
|
||||||
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
|
|
||||||
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
|
|
||||||
`make build` calls the new `script/build`, which stamps the version (`VERSION`
|
|
||||||
from the environment, else `git describe`) and the git commit as before.
|
|
||||||
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
|
|
||||||
depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
|
|
||||||
first.
|
|
||||||
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
|
|
||||||
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
|
|
||||||
`/secret`, `*.log`, `*.test` and `settings.local.json`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
|
|
||||||
`node_modules`; `.git` stays in the build context for the version stamp.
|
|
||||||
- 2026-10-04: `secret init` refuses when the default vault exists, and
|
|
||||||
`secret vault create NAME` when `NAME` does, with "vault NAME already exists",
|
|
||||||
before writing anything. The check is in `vault.CreateVault`, which both
|
|
||||||
commands call while holding the state directory lock, so two creates of one
|
|
||||||
vault at once cannot both pass the check. Before, either command replaced the
|
|
||||||
vault's metadata, passphrase unlocker and `longterm.age`, so none of its
|
|
||||||
secrets could be decrypted any more. Both commands now ask for the unlocker
|
|
||||||
passphrase before creating the vault, so one stopped at that prompt leaves no
|
|
||||||
vault behind.
|
|
||||||
- 2026-10-04: The `internal/cli` tests are back to about their time before the
|
|
||||||
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
|
|
||||||
that each changing command waits for the lock releases it as soon as it sees
|
|
||||||
the command waiting there, instead of after a fixed 100 ms. The two vaults
|
|
||||||
with passphrase unlockers that the path and move tests start from are made
|
|
||||||
once and copied for each test.
|
|
||||||
- 2026-10-04: `secret mv` rejects a move whose destination is the source under
|
|
||||||
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
|
|
||||||
macOS default) or a name reached through a symbolic link, before changing
|
|
||||||
anything, with or without `--force`, within a vault and between vaults;
|
|
||||||
before, `--force` removed the destination and so deleted the secret. A rename
|
|
||||||
that changes only letter case works on a case-sensitive filesystem as before.
|
|
||||||
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
|
|
||||||
where golangci-lint is a build step rebuilt on every run
|
|
||||||
(`--no-cache-filter`), so an unchanged tree is linted too; the module download
|
|
||||||
stays cached. `script/bootstrap` no longer installs golangci-lint, and the
|
|
||||||
`Dockerfile` lint stage calls it directly instead of `make lint`.
|
|
||||||
`golangci-lint config verify` is not run: it fetches its schema live over
|
|
||||||
unpinned HTTPS.
|
|
||||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
|
|
||||||
panics: `GetID()` warns with the unlocker's directory and returns
|
|
||||||
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
|
|
||||||
metadata file cannot be checked for, read or parsed instead of failing, so
|
|
||||||
`secret unlocker list` still lists the others; the listing's ID lookup no
|
|
||||||
longer warns about that directory again.
|
|
||||||
- 2026-10-03: `secret mv` rejects a move whose destination is the source
|
|
||||||
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
|
|
||||||
defaults to the source name) before changing anything; before, `--force`
|
|
||||||
removed the destination first and so deleted the secret. Every vault name
|
|
||||||
given with `vault:` must be one of the existing vaults by exact name, so
|
|
||||||
`work:x work/:x` is rejected instead of being taken for a move between two
|
|
||||||
vaults. A move within a named vault no longer makes that vault the current
|
|
||||||
one, whether it succeeds or fails.
|
|
||||||
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on
|
|
||||||
`lock` in the state directory; a mutex on the in-memory test filesystem), so
|
|
||||||
concurrent commands no longer lose versions or race on the current pointers.
|
|
||||||
Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
|
|
||||||
rename), so no file is ever half-written and `current`, `currentvault` and
|
|
||||||
`current-unlocker` never go missing. New versions, new secrets and cross-vault
|
|
||||||
copies are built in a temporary directory and renamed into place, and removals
|
|
||||||
rename out of the way first, so a version or secret is never half-added and
|
|
||||||
never half-removed.
|
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an error
|
|
||||||
naming the path and cause when they cannot read what they inspect, instead of
|
|
||||||
reading the failure as "nothing there": the duplicate check before
|
|
||||||
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
|
|
||||||
the secret count that guards removing the last unlocker and removing a vault,
|
|
||||||
and the existing long-term key check before `vault import`.
|
|
||||||
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a
|
|
||||||
version only if it is one of the versions `version list` lists for that
|
|
||||||
secret, compared as typed before any path is built (`secret.VersionExists`),
|
|
||||||
and touch nothing otherwise. An empty `--version` is rejected instead of
|
|
||||||
meaning the current version. Before, `secret version rm x ../../..` deleted
|
|
||||||
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
|
|
||||||
version.
|
|
||||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
|
|
||||||
code after its deferred `memguard.Purge()` has run, and only `main` calls
|
|
||||||
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
|
|
||||||
buffer before exiting; when the process is in the terminal's foreground
|
|
||||||
process group it first restores the terminal settings from startup, so an
|
|
||||||
interrupted passphrase prompt no longer leaves echo off.
|
|
||||||
- 2026-10-03: Every command that builds a path from a secret name checks the
|
|
||||||
name first with `vault.ValidateSecretName` and touches nothing when it is
|
|
||||||
invalid: `rm`, `mv` (both names, within a vault and between vaults, before
|
|
||||||
switching the current vault), `import`, `version list`/`promote`/`rm`,
|
|
||||||
`encrypt` and `decrypt`. The error and `README.md` state the naming rule.
|
|
||||||
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
|
|
||||||
in it.
|
|
||||||
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
|
|
||||||
it is generated into a locked buffer, and the keychain JSON is written and
|
|
||||||
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
|
|
||||||
Linux) without `encoding/json` holding it; the JSON field names are unchanged.
|
|
||||||
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
|
|
||||||
that needs more locked memory than the process can lock, and run every case
|
|
||||||
under `script/cibuild`. The image stamps the `VERSION` build argument, else
|
|
||||||
`git describe --tags --always`, into `Version`, and fails if `.git` is present
|
|
||||||
but yields no version; `make build` stamps `git describe` too, not a fixed
|
|
||||||
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
|
|
||||||
canonical copy.
|
|
||||||
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
|
||||||
`.golangci.yml` (all linters enabled minus the standard disable list, `lll`
|
|
||||||
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
|
|
||||||
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
|
|
||||||
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
|
|
||||||
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
|
|
||||||
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
|
|
||||||
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
|
|
||||||
`pkg/bip85` were reshaped so every composed error message is byte-identical to
|
|
||||||
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
|
||||||
skips an unreadable `unlockers.d` entry with a warning instead of emitting a
|
|
||||||
fabricated fallback ID.
|
|
||||||
- 2026-08-07: Added `.editorconfig`
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/27).
|
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
|
||||||
shims, README Entrypoints section
|
|
||||||
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
|
||||||
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
|
||||||
- 2026-03-30: Added the `make fmt-check` target and
|
|
||||||
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
|
||||||
`Dockerfile` base images are pinned by sha256.
|
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
|
|
||||||
plus review fixes (stub panics, derivation index, tests, README) on branch
|
|
||||||
secure-enclave-unlocker.
|
|
||||||
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
|
|
||||||
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
|
|
||||||
metadata, allow uppercase secret names, fix hardcoded derivation index,
|
|
||||||
validate names in GetSecretVersion against path traversal, return errors
|
|
||||||
instead of panicking, add Warn() on silent anomalies.
|
|
||||||
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths
|
|
||||||
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
|
|
||||||
removed.
|
|
||||||
- Per-secret keypair architecture, vault package refactor, versioning with
|
|
||||||
--version, comprehensive test suite with in-memory filesystem.
|
|
||||||
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
|
|
||||||
- Renamed SEP unlocker to Keychain, reorganized import commands.
|
|
||||||
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
|
|
||||||
|
|
||||||
# Future Steps
|
# 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` (`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 `pkg/agehd/agehd_test.go`
|
||||||
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
|
`TestMnemonicVsXPRVConsistency`.
|
||||||
- CI does not compile, lint or test the files built only with cgo on macOS,
|
- No macOS test runs in CI: `internal/secret/pgpunlock_test.go` builds only on
|
||||||
since compiling them needs Apple's SDK:
|
macOS. A macOS runner would cover it.
|
||||||
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
|
- 1.0 critical security blockers:
|
||||||
`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
|
- Memory security: age writes an identity's private key out as a string in
|
||||||
ordinary memory, and the copies it makes on the way stay there
|
ordinary memory, and the copies it makes on the way stay there
|
||||||
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
||||||
@@ -797,6 +32,6 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
- Cleanups: read statedir from environment or default instead of passing it
|
- Cleanups: read statedir from environment or default instead of passing it
|
||||||
around.
|
around.
|
||||||
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
||||||
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
miss, audit logging, hardware integration tests (GPG), naming consistency,
|
||||||
consistency, vault export/import, batch operations, search, secret metadata
|
vault export/import, batch operations, search, secret metadata (descriptions,
|
||||||
(descriptions, tags).
|
tags).
|
||||||
Reference in new issue
Block a user