Format and check markdown with prettier in make fmt and fmt-check (closes #110)
check / check (push) Failing after 3s

script/fmt and script/fmt-check follow the model scripts in the prompts
repo: Go as before, plus prettier over every markdown file with 4-space
tabs and proseWrap always. Prettier is pinned by hash in package.json and
yarn.lock; script/bootstrap now installs node, yarn and prettier. The
Dockerfile lint stage copies node and yarn from a node image pinned by
hash and runs script/bootstrap, so its make fmt-check fails the build on
unformatted markdown. Every markdown file is formatted once; wording is
unchanged (CLAUDE.md's "*" list markers become "-").

Model: opus-5-5
This commit was merged in pull request #117.
This commit is contained in:
2026-10-05 02:07:54 +02:00
parent 2503f2db96
commit 047f347955
11 changed files with 589 additions and 515 deletions
+71 -73
View File
@@ -1,95 +1,93 @@
# IMPORTANT RULES # IMPORTANT RULES
* Claude is an inanimate tool. The spam that Claude attempts to insert into - Claude is an inanimate tool. The spam that Claude attempts to insert into
commit messages (which it erroneously refers to as "attribution") is not commit messages (which it erroneously refers to as "attribution") is not
attribution, as I am the sole author of code created using Claude. It is attribution, as I am the sole author of code created using Claude. It is
corporate advertising for Anthropic and is therefore completely corporate advertising for Anthropic and is therefore completely unacceptable
unacceptable in commit messages. in commit messages.
* Tests should always be run before committing code. No commits should be - Tests should always be run before committing code. No commits should be made
made that do not pass tests. that do not pass tests.
* Code should always be formatted before committing. Do not commit - Code should always be formatted before committing. Do not commit unformatted
unformatted code. code.
* Code should always be linted and linter errors fixed before committing. - Code should always be linted and linter errors fixed before committing. NEVER
NEVER commit code that does not pass the linter. DO NOT modify the linter commit code that does not pass the linter. DO NOT modify the linter config
config unless specifically instructed. unless specifically instructed.
* The test suite is fast and local. When running tests, NEVER run - The test suite is fast and local. When running tests, NEVER run individual
individual parts of the test suite, always run the whole thing by running parts of the test suite, always run the whole thing by running "make test".
"make test".
* Do not stop working on a task until you have reached the definition of - Do not stop working on a task until you have reached the definition of done
done provided to you in the initial instruction. Don't do part or most of provided to you in the initial instruction. Don't do part or most of the work,
the work, do all of the work until the criteria for done are met. do all of the work until the criteria for done are met.
* When you complete each task, if the tests are passing and the code is - When you complete each task, if the tests are passing and the code is
formatted and there are no linter errors, always commit and push your formatted and there are no linter errors, always commit and push your work.
work. Use a good commit message and don't mention any author or co-author Use a good commit message and don't mention any author or co-author
attribution. attribution.
* Do not create additional files in the root directory of the project - Do not create additional files in the root directory of the project without
without asking permission first. Configuration files, documentation, and asking permission first. Configuration files, documentation, and build files
build files are acceptable in the root, but source code and other files are acceptable in the root, but source code and other files should be
should be organized in appropriate subdirectories. organized in appropriate subdirectories.
* Do not use bare strings or numbers in code, especially if they appear - Do not use bare strings or numbers in code, especially if they appear anywhere
anywhere more than once. Always define a constant (usually at the top of more than once. Always define a constant (usually at the top of the file) and
the file) and give it a descriptive name, then use that constant in the give it a descriptive name, then use that constant in the code instead of the
code instead of the bare string or number. bare string or number.
* If you are fixing a bug, write a test first that reproduces the bug and - If you are fixing a bug, write a test first that reproduces the bug and fails,
fails, and then fix the bug in the code, using the test to verify that the and then fix the bug in the code, using the test to verify that the fix
fix worked. worked.
* When implementing new features, be aware of potential side-effects (such - When implementing new features, be aware of potential side-effects (such as
as state files on disk, data in the database, etc.) and ensure that it is state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests when designing an possible to mock or stub these side-effects in tests when designing an API.
API.
* When dealing with dates and times or timestamps, always use, display, and - When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs store UTC. Set the local timezone to UTC on startup. If the user needs to see
to see the time in a different timezone, store the user's timezone in a the time in a different timezone, store the user's timezone in a separate
separate field and convert the UTC time to the user's timezone when field and convert the UTC time to the user's timezone when displaying it. For
displaying it. For internal use and internal applications and internal use and internal applications and administrative purposes, always
administrative purposes, always display UTC. display UTC.
* When implementing programs, put the main.go in - When implementing programs, put the main.go in ./cmd/<program_name>/main.go
./cmd/<program_name>/main.go and put the program's code in and put the program's code in ./internal/<program_name>/. This allows for
./internal/<program_name>/. This allows for multiple programs to be multiple programs to be implemented in the same repository without cluttering
implemented in the same repository without cluttering the root directory. the root directory. main.go should simply import and call
main.go should simply import and call <program_name>.CLIEntry(). The <program_name>.CLIEntry(). The full implementation should be in
full implementation should be in ./internal/<program_name>/. ./internal/<program_name>/.
* When you are instructed to make the tests pass, DO NOT delete tests, skip - When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there tests, or change the tests specifically to make them pass (unless there is a
is a bug in the test). This is cheating, and it is bad. You should only bug in the test). This is cheating, and it is bad. You should only be
be modifying the test if it is incorrect or if the test is no longer modifying the test if it is incorrect or if the test is no longer relevant. In
relevant. In almost all cases, you should be fixing the code that is almost all cases, you should be fixing the code that is being tested, or
being tested, or updating the tests to match a refactored implementation. updating the tests to match a refactored implementation.
* Always write a `Makefile` with the default target being `test`, and with a - Always write a `Makefile` with the default target being `test`, and with a
`fmt` target that formats the code. The `test` target should run all `fmt` target that formats the code. The `test` target should run all tests in
tests in the project, and the `fmt` target should format the code. `test` the project, and the `fmt` target should format the code. `test` should also
should also have a prerequisite target `lint` that should run any linters have a prerequisite target `lint` that should run any linters that are
that are configured for the project. configured for the project.
* After each completed bugfix or feature, the code must be committed. Do - After each completed bugfix or feature, the code must be committed. Do all of
all of the pre-commit checks (test, lint, fmt) before committing, of the pre-commit checks (test, lint, fmt) before committing, of course. After
course. After each commit, push to the remote. each commit, push to the remote.
* Always write tests, even if they are extremely simple and just check for - Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new correct syntax (ability to compile/import). If you are writing a new feature,
feature, write a test for it. You don't need to target complete coverage, write a test for it. You don't need to target complete coverage, but you
but you should at least test any new functionality you add. should at least test any new functionality you add.
* Always use structured logging. Log any relevant state/context with the - Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output messages (but do not log secrets). If stdout is not a terminal, output the
the structured logs in jsonl format. Use go's log/slog. structured logs in jsonl format. Use go's log/slog.
* You do not need to summarize your changes in the chat after making them. - You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out of Making the changes and committing them is sufficient. If anything out of the
the ordinary happened, please explain it, but in the normal case where you ordinary happened, please explain it, but in the normal case where you found
found and fixed the bug, or implemented the feature, there is no need for and fixed the bug, or implemented the feature, there is no need for the
the end-of-change summary. end-of-change summary.
+14 -2
View File
@@ -1,10 +1,22 @@
# node and yarn, copied into the lint stage for prettier, which checks the
# markdown formatting: node of the version script/bootstrap pins, built on
# Debian as the lint stage's image is.
# node:22.17.0-bookworm-slim, 2025-07-08
FROM node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0 AS node
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn-v1.22.22 /opt/yarn-v1.22.22
ENV PATH="/opt/yarn-v1.22.22/bin:${PATH}"
# script/bootstrap downloads the Go modules and installs prettier
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY script/ script/
RUN go mod download COPY go.mod go.sum package.json yarn.lock ./
RUN script/bootstrap
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps # script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
# below run again on each build, an unchanged tree included, while the # below run again on each build, an unchanged tree included, while the
+9 -4
View File
@@ -593,8 +593,10 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We development workflow, and the Makefile targets are thin shims that call them. We
provide: provide:
- `script/bootstrap` — install all dependencies (Go, Go module download), - `script/bootstrap` — install all dependencies (Go, Go module download, and
idempotently; golangci-lint is not installed, it runs in docker node, yarn and prettier for formatting markdown), idempotently; prettier is
pinned by hash in `package.json` and `yarn.lock`; golangci-lint is not
installed, it runs in docker
- `script/setup` — make a fresh clone ready for development: runs - `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by other - `script/projectname` — output the project name (`secret`); used by other
@@ -611,8 +613,11 @@ provide:
compiles; cgo is off, so the keychain unlocker's calls into the keychain compiles; cgo is off, so the keychain unlocker's calls into the keychain
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`) (`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
and the Secure Enclave bindings (`internal/macse`) are not checked and the Secure Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes) - `script/fmt` — format all Go code with `go fmt` and every markdown file with
- `script/fmt-check` — check formatting without writing prettier (4-space tabs, `proseWrap: always`) (writes)
- `script/fmt-check` — check the same formatting without writing; the
`Dockerfile` lint stage runs it, so an unformatted Go or markdown file fails
the build
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and - `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
`script/fmt-check` `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
+374 -394
View File
@@ -18,462 +18,442 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 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 - 2026-10-04: A mnemonic that cannot be read, in `secret init` and
`secret vault create`, gives an error that names the mnemonic only `secret vault create`, gives an error that names the mnemonic only
(https://git.eeqj.de/sneak/secret/issues/115). It is read with (https://git.eeqj.de/sneak/secret/issues/115). It is read with
`secret.ReadMnemonic`, whose every error wraps the new `secret.ReadMnemonic`, whose every error wraps the new
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so `secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
the message said "failed to read mnemonic: failed to read passphrase:" and message said "failed to read mnemonic: failed to read passphrase:" and advised
advised setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
"failed to read mnemonic: stdin is not a terminal (piped input or script). mnemonic: stdin is not a terminal (piped input or script). Please set the
Please set the SB_SECRET_MNEMONIC environment variable or run SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
interactively". The passphrase messages no longer repeat "cannot read messages no longer repeat "cannot read passphrase" after "failed to read
passphrase" after "failed to read passphrase:", and empty input gives passphrase:", and empty input gives "nothing was entered".
"nothing was entered". - 2026-10-04: A failure returns the same error value whichever command hits it
- 2026-10-04: A failure returns the same error value whichever command hits (https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
it (https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
keeps its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`, `ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap `vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
the `vault` errors. `errUnsupportedUnlockerType` is removed: `secret `vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
unlocker add` gives `errInvalidUnlockerType` for an unknown type, whichever gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
check rejects it. Off macOS, adding a keychain or Secure Enclave unlocker it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
returns the `secret` package's error for it, not an `internal/cli` copy; on `secret` package's error for it, not an `internal/cli` copy; on macOS, the
macOS, the check that the system is macOS is gone, as it could never fail. check that the system is macOS is gone, as it could never fail.
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid `secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
mnemonic, as `init` and `vault create` do. `secret generate secret` gives mnemonic, as `init` and `vault create` do. `secret generate secret` gives
`errLengthTooSmall` for a length below 1 wherever it is checked, and `errLengthTooSmall` for a length below 1 wherever it is checked, and
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a `errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it. file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which `vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
`secret` already returned under another name. Messages are unchanged, `secret` already returned under another name. Messages are unchanged, except
except that `secret decrypt` of a missing secret says "not found", as that `secret decrypt` of a missing secret says "not found", as `secret get`
`secret get` does, not "does not exist"; `vault import` of an invalid does, not "does not exist"; `vault import` of an invalid mnemonic says
mnemonic says "invalid BIP39 mnemonic phrase"; `--type mnemonic` says "invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
"unsupported type: mnemonic (use 'secret generate mnemonic' instead)"; and mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
a file too large to import says import says
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`. `failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`, Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
which supplies the words "failed to read passphrase" that its callers used which supplies the words "failed to read passphrase" that its callers used to
to add themselves; so two passphrases that differ now give only add themselves; so two passphrases that differ now give only "passphrases do
"passphrases do not match", the words now follow "failed to read mnemonic:" not match", the words now follow "failed to read mnemonic:" and "failed to
and "failed to read passphrase confirmation:", and a terminal read error no read passphrase confirmation:", and a terminal read error no longer repeats
longer repeats them. A GPG key the keyring does not hold gives them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
`secret.ErrGPGKeyNotFound`, found by gpg's status line for "No public key"; found by gpg's status line for "No public key"; before, the message repeated
before, the message repeated "failed to resolve GPG key fingerprint" and "failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
ended in gpg's exit status. The keychain unlocker returns `errNilDataBuffer` keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
for nil data; this and its test build only on macOS with cgo and were only build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
read. `bip85.ErrPasswordTooShort` and `ErrEncodedTooShort` are removed with and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
their checks: 64 bytes of entropy always give 86 Base64 or 80 Base85 always give 86 Base64 or 80 Base85 characters, the most a password length may
characters, the most a password length may ask for. Tests that matched ask for. Tests that matched these errors' text use `errors.Is`.
these errors' text use `errors.Is`. - 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
not by matching words of its message Every exported error that can be returned has a test that the function returns
(https://git.eeqj.de/sneak/secret/issues/49). Every exported error that it, and errors wrapping a cause are checked through the wrapping. Checks that
can be returned has a test that the function returns it, and errors still match text, because the error has no exported value the test can name,
wrapping a cause are checked through the wrapping. Checks that still match are listed on the issue.
text, because the error has no exported value the test can name, are
listed on the issue.
- 2026-10-04: When a vault cannot be opened through its current unlocker, - 2026-10-04: When a vault cannot be opened through its current unlocker,
because a file the unlocker needs is missing or damaged, its keychain item because a file the unlocker needs is missing or damaged, its keychain item or
or Secure Enclave key is gone, or the passphrase is wrong, the error now Secure Enclave key is gone, or the passphrase is wrong, the error now ends by
ends by naming the vault, saying that it still opens with its mnemonic, naming the vault, saying that it still opens with its mnemonic, and that
and that `secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` `secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it,
set to it, gives the vault a new unlocker; for a vault that is not the gives the vault a new unlocker; for a vault that is not the current one, as in
current one, as in `secret move` between vaults, it says to run `secret move` between vaults, it says to run `secret vault select` first
`secret vault select` first (https://git.eeqj.de/sneak/secret/issues/47). (https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare
Before, it ended with the bare cause. The advice is given only when the cause. The advice is given only when the vault metadata records the key the
vault metadata records the key the mnemonic derives, so not for a vault mnemonic derives, so not for a vault created without a mnemonic, and not when
created without a mnemonic, and not when the passphrase could not be read the passphrase could not be read at all. `secret vault import` is not named:
at all. `secret vault import` is not named: it refuses a vault that has a it refuses a vault that has a long-term key. `secret encrypt` and
long-term key. `secret encrypt` and `secret decrypt` now read the key `secret decrypt` now read the key secret through `vault.GetSecret`, as
secret through `vault.GetSecret`, as `secret get` does, so they give the `secret get` does, so they give the same advice; `Secret.GetValue`, the other
same advice; `Secret.GetValue`, the other way to get the long-term key, is way to get the long-term key, is removed. When a secret's `current` file
removed. When a secret's `current` file cannot be read, the error says cannot be read, the error says that `secret version list` lists its versions
that `secret version list` lists its versions and `secret version promote` and `secret version promote` makes one current. The causes stay wrapped.
makes one current. The causes stay wrapped. - 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, no two unlockers of a vault share one
so no two unlockers of a vault share one
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure (https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
Enclave unlocker's ID was its creation time to the minute and the host name, Enclave unlocker's ID was its creation time to the minute and the host name,
and a passphrase unlocker's the time to the minute, so two created within a and a passphrase unlocker's the time to the minute, so two created within a
minute shared an ID, and `unlocker select`, `unlocker remove` and the minute shared an ID, and `unlocker select`, `unlocker remove` and the
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID selection `unlocker add` makes acted on the older one. A PGP unlocker's ID was
was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is `pgp-` and its key's fingerprint; a second PGP unlocker for a key is still
still refused, now by comparing the fingerprint in the other unlockers' refused, now by comparing the fingerprint in the other unlockers' metadata.
metadata. `unlocker list` and the shell completion of `unlocker select` and `unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read `unlocker remove` take each ID from the directory the unlocker was read from,
from, no longer by matching metadata, so two unlockers with the same no longer by matching metadata, so two unlockers with the same metadata are
metadata are listed apart; an unlocker of an unknown type is listed under listed apart; an unlocker of an unknown type is listed under its directory
its directory name, and completion now offers Secure Enclave unlockers too. name, and completion now offers Secure Enclave unlockers too. The keychain and
The keychain and Secure Enclave code was type-checked by Secure Enclave code was type-checked by `script/lint-darwin`, never run; a
`script/lint-darwin`, never run; a test on Linux lists, completes, selects test on Linux lists, completes, selects and removes each of two passphrase
and removes each of two passphrase unlockers with the same metadata by its unlockers with the same metadata by its own ID.
own ID. - 2026-10-04: README's Storage Architecture, `secret version promote`, Technical
- 2026-10-04: README's Storage Architecture, `secret version promote`, Details and Testing text matches the code
Technical Details and Testing text matches the code (https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault`
(https://git.eeqj.de/sneak/secret/issues/102). `current` and are plain files holding a name, not symbolic links; a version's metadata is
`currentvault` are plain files holding a name, not symbolic links; a the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret`
version's metadata is the encrypted `metadata.age`; the state directory is in the user's configuration directory, not `~/.local/share/secret`, and holds
`berlin.sneak.pkg.secret` in the user's configuration directory, not the `lock` file. Also corrected: the code sets up no Touch ID for the keychain
`~/.local/share/secret`, and holds the `lock` file. Also corrected: the or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version
code sets up no Touch ID for the keychain or Secure Enclave unlocker, and keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata
the Secure Enclave only decrypts; per-version keys give no forward holds no vault name. Testing lists only `make test`.
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or - 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105). not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
`vault.CreateVault` now takes the unlocker passphrase too, writes the vault now takes the unlocker passphrase too, writes the vault directory with its
directory with its metadata, long-term public key and passphrase unlocker, metadata, long-term public key and passphrase unlocker, `longterm.age`
`longterm.age` included, into a temporary directory, renames that into included, into a temporary directory, renames that into `vaults.d` once it is
`vaults.d` once it is complete, and only then makes the vault current. complete, and only then makes the vault current. Before, either command killed
Before, either command killed after the passphrase prompt but before the after the passphrase prompt but before the unlocker was written left a vault
unlocker was written left a vault with no unlocker, which `vault create` had with no unlocker, which `vault create` had already made current and which
already made current and which neither command would create again. Killed neither command would create again. Killed part-way now, it leaves no vault,
part-way now, it leaves no vault, and the next command that takes the lock and the next command that takes the lock deletes the temporary directory; or,
deletes the temporary directory; or, killed between the rename and making killed between the rename and making the vault current, a complete vault that
the vault current, a complete vault that is not current, which is not current, which `secret vault select` makes current.
`secret vault select` makes current.
- 2026-10-04: A failed `secret unlocker add keychain` or - 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item or `secret unlocker add secure-enclave` no longer leaves its keychain item or
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89). Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the `CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
Secure Enclave key, so that a wrong passphrase creates none, and deletes the Secure Enclave key, so that a wrong passphrase creates none, and deletes the
key again if encrypting with it or writing the unlocker then fails. key again if encrypting with it or writing the unlocker then fails.
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates `macse.CreateKey` finds the new key's hash right after `sc_auth` creates it,
it, and fails with an error naming the key's label if it cannot; it deletes and fails with an error naming the key's label if it cannot; it deletes the
the key again if getting its public key then fails. The Objective-C was only key again if getting its public key then fails. The Objective-C was only read,
read, never compiled or run, and so was `macse_darwin.go`, which is cgo only. never compiled or run, and so was `macse_darwin.go`, which is cgo only.
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata `CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
among them, before it stores the item in the keychain, and deletes the item among them, before it stores the item in the keychain, and deletes the item
again if moving the unlocker into place then fails. A failure to delete is again if moving the unlocker into place then fails. A failure to delete is
reported along with the first error. The tests of this run only on macOS: reported along with the first error. The tests of this run only on macOS: the
the Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the
the keychain one in a build with cgo. keychain one in a build with cgo.
- 2026-10-04: What a command killed part-way left under a `.tmp-` name - 2026-10-04: What a command killed part-way left under a `.tmp-` name
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories (https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
of `secret.TempDirFor` and the temporary files of `secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
`secret.WriteFileAtomic`, encrypted keys included, is deleted by the next encrypted keys included, is deleted by the next command that takes the state
command that takes the state directory lock. Before, it stayed until directory lock. Before, it stayed until deleted by hand. A command writes
deleted by hand. A command writes `finished` into the lock file just `finished` into the lock file just before it releases the lock; the next one
before it releases the lock; the next one to take the lock searches only to take the lock searches only when it does not find that, so after a command
when it does not find that, so after a command that finished nothing is that finished nothing is searched, however many secrets and versions there
searched, however many secrets and versions there are. The search looks are. The search looks in the state directory, each vault, each secret and each
in the state directory, each vault, each secret and each version, the version, the only directories those helpers make them in. A command that only
only directories those helpers make them in. A command that only reads reads takes no lock and deletes nothing. A failure to delete is warned about
takes no lock and deletes nothing. A failure to delete is warned about
and the command goes on. An unlocker directory with no metadata file was and the command goes on. An unlocker directory with no metadata file was
already removed by `secret unlocker remove` given its directory name; a already removed by `secret unlocker remove` given its directory name; a test
test now shows it. now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer - 2026-10-04: An age identity's private key goes into a locked buffer through
through `secret.IdentityToLockedBuffer` everywhere `secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key (https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
when a passphrase, PGP, keychain or Secure Enclave unlocker is created, passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
the new unlocker's own key, a new secret version's key, and the key unlocker's own key, a new secret version's key, and the key `secret encrypt`
`secret encrypt` generates. Before, each place converted the string age generates. Before, each place converted the string age returns to bytes and
returns to bytes and left the string in ordinary memory. The function left the string in ordinary memory. The function moves the string's own bytes
moves the string's own bytes into the buffer, which overwrites them; the into the buffer, which overwrites them; the copies age makes while writing the
copies age makes while writing the string remain, as its comment says. string remain, as its comment says. The 1.0 memory-security entry below no
The 1.0 memory-security entry below no longer lists these places, longer lists these places, `internal/cli/crypto.go` among them, nor
`internal/cli/crypto.go` among them, nor `version.go:155`, which was `version.go:155`, which was `internal/secret/version.go`, not
`internal/secret/version.go`, not `internal/cli/version.go`. `internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and - 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50).
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and `script/check` runs it, and the `Dockerfile` lint stage runs its commands, so
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too. `script/cibuild` does too. Before, CI on Linux never compiled the files built
Before, CI on Linux never compiled the files built only for macOS. Compiling only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS.
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions So the three functions that call `go-keychain` moved from
that call `go-keychain` moved from `keychainunlocker.go` to `keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on
`keychainunlocker_cgo.go`, built only with cgo on macOS like macOS like `macse_darwin.go`. A macOS build without cgo, which before did not
`macse_darwin.go`. A macOS build without cgo, which before did not compile, compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors errors say the keychain or Secure Enclave needs a macOS build with cgo. The
say the keychain or Secure Enclave needs a macOS build with cgo. The check check covers the rest of the keychain unlocker, the Secure Enclave unlocker
covers the rest of the keychain unlocker, the Secure Enclave unlocker and and the macOS-only tests other than `keychainunlocker_test.go`, whose lint
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved `GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into
into functions of their own, and the Secure Enclave unlocker derives the functions of their own, and the Secure Enclave unlocker derives the long-term
long-term key from the mnemonic through the same function as the keychain key from the mnemonic through the same function as the keychain unlocker
unlocker instead of a copy of it. Lines over 88 columns in the files the instead of a copy of it. Lines over 88 columns in the files the check cannot
check cannot see are wrapped. see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and - 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything `secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the (https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then last, and for the last the vault's secret count and that the vault then opens
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
`--force`, a command whose stdin is not a terminal fails at once. `--force` command whose stdin is not a terminal fails at once. `--force` (now also on
(now also on `rm` and `version rm`) removes without asking; it replaces the `rm` and `version rm`) removes without asking; it replaces the old refusals to
old refusals to remove a vault with secrets or the last unlocker of one remove a vault with secrets or the last unlocker of one without `--force`,
without `--force`, which the question now covers. The checks run, and the which the question now covers. The checks run, and the question is asked,
question is asked, before the state directory lock is taken; under the before the state directory lock is taken; under the lock the checks run again,
lock the checks run again, and if they would ask a different question, and if they would ask a different question, nothing is removed. `secret rm`
nothing is removed. `secret rm` fails when it cannot count the versions. fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a - 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a (https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond: directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure `passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
Enclave unlocker the keychain item or Secure Enclave key, which names the unlocker the keychain item or Secure Enclave key, which names the directory,
directory, carries the time instead of the day. `secret.WriteDir` fails on a carries the time instead of the day. `secret.WriteDir` fails on a directory
directory that exists instead of writing into it. `unlocker add passphrase` that exists instead of writing into it. `unlocker add passphrase` writes the
writes the new unlocker, makes it current, and only then removes the vault's new unlocker, makes it current, and only then removes the vault's other
other passphrase unlockers; a crash between the last two steps leaves the old passphrase unlockers; a crash between the last two steps leaves the old one
one beside the new, and the old passphrase still opens the vault through it beside the new, and the old passphrase still opens the vault through it until
until the next `unlocker add passphrase` or an `unlocker remove` removes it. the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
A PGP, keychain or Secure Enclave unlocker added on the same host and day as keychain or Secure Enclave unlocker added on the same host and day as another
another of its type is added beside it instead of replacing it. of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
unset at once, so that no program the command runs, `gpg` included, at once, so that no program the command runs, `gpg` included, inherits them
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below (https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
the command reads the environment; the buffers are passed down: the environment; the buffers are passed down: `vault.CreateVault` takes the
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its mnemonic (nil for none), a `Vault` derives its long-term key from its
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a `Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker PGP, keychain and Secure Enclave unlocker constructors take both.
constructors take both. `CreatePGPUnlocker` sets both on the vault it `CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of `SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and `GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
`vault create` no longer put the mnemonic into the environment. Unsetting into the environment. Unsetting erases nothing: the starting environment
erases nothing: the starting environment (`/proc/<pid>/environ`) and (`/proc/<pid>/environ`) and memory still hold the value. The README warns
memory still hold the value. The README warns against both variables. against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from - 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
`sneak/prompts`, byte for byte byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2` `gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
in place of the deprecated `gomodguard`, so the lint no longer warns, warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
and enables `depguard` with a rule that keeps `net/http/httptest` out of of non-test files. Neither raised a finding in this repo.
non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux - 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets (https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
the vault's long-term key as adding a passphrase unlocker does, with the vault's long-term key as adding a passphrase unlocker does, with the vault's
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
mnemonic, checked against the vault, or else from the current unlocker. checked against the vault, or else from the current unlocker. Before, it used
Before, it used the keychain unlocker's helper, which on every platform the keychain unlocker's helper, which on every platform but macOS always
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
key, getting the long-term key once from the mnemonic and once from a long-term key once from the mnemonic and once from a passphrase unlocker, and
passphrase unlocker, and reads a secret through the new unlocker. reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, - 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
`.`, `-` and `_`, and must not be empty, `.` or `..` `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` (https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
state the rule. `vault create`, `vault import`, `vault select`, the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
`vault remove`, both vault names of `mv` and shell completion of a vault names of `mv` and shell completion of a `vault:secret` argument check
`vault:secret` argument check the name as typed with the name as typed with `vault.ValidateVaultName` before building any path from
`vault.ValidateVaultName` before building any path from it. Before, it. Before, `vault import ..` wrote a long-term key and an unlocker into the
`vault import ..` wrote a long-term key and an unlocker into the state state directory itself, and `vault select ..` made that the current vault.
directory itself, and `vault select ..` made that the current vault. - 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged (https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the the `CHECK_EPOCH` build argument, which both the lint and the build stage of
current time as the `CHECK_EPOCH` build argument, which both the lint the `Dockerfile` declare after their module download, so the `RUN` steps below
and the build stage of the `Dockerfile` declare after their module the argument run again on each build while the base images and module
download, so the `RUN` steps below the argument run again on each downloads stay cached. Before, a second run on the same tree took every check
build while the base images and module downloads stay cached. Before, from the build cache and reported success having run nothing.
a second run on the same tree took every check from the build cache
and reported success having run nothing.
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker - 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48). directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for `secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
its duplicate check, and passes it to `CreatePGPUnlocker` to record. duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key `CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
and encrypt everything before writing anything. All four unlocker encrypt everything before writing anything. All four unlocker types write
types write their files through `secret.WriteDir`: a new unlocker is their files through `secret.WriteDir`: a new unlocker is built in a temporary
built in a temporary directory, renamed into place when complete and directory, renamed into place when complete and removed on a failure.
removed on a failure. - 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` the warning `unlocker list` gives, an unlocker directory whose metadata file
skip, with the warning `unlocker list` gives, an unlocker directory cannot be checked for, read or parsed, instead of failing when it sorts before
whose metadata file cannot be checked for, read or parsed, instead of the unlocker asked for. Such a directory, or one without a metadata file, is
failing when it sorts before the unlocker asked for. Such a directory, removed by its directory name, the name the warning gives; only the directory
or one without a metadata file, is removed by its directory name, the is removed, since its type is unknown. Removing one whose metadata file is
name the warning gives; only the directory is removed, since its type missing or corrupt never counts as removing the last unlocker. Removing one
is unknown. Removing one whose metadata file is missing or corrupt whose metadata file cannot be checked for or read always does, since it may be
never counts as removing the last unlocker. Removing one whose metadata the only working unlocker, so in a vault with secrets it needs `--force`.
file cannot be checked for or read always does, since it may be the - 2026-10-04: A failed command prints its error once, without the usage text
only working unlocker, so in a vault with secrets it needs `--force`. after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
- 2026-10-04: A failed command prints its error once, without the usage for a command called wrongly: wrong number of arguments, unknown flag, bad
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is flag value, missing required flag, or flags that break a flag group (mutually
still printed for a command called wrongly: wrong number of arguments, exclusive, required together, one required). The root command's
unknown flag, bad flag value, missing required flag, or flags that `PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
break a flag group (mutually exclusive, required together, one before that hook but required flags and flag groups only after it, so the hook
required). The root command's `PersistentPreRunE` turns usage off. checks those two first. Root `SilenceUsage` would have hidden usage for all of
Cobra checks arguments and flag values before that hook but required these.
flags and flag groups only after it, so the hook checks those two - 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
first. Root `SilenceUsage` would have hidden usage for all of these. out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
- 2026-10-04: `secret get` keeps the secret in locked memory until it `Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
writes it out (https://git.eeqj.de/sneak/secret/issues/37): destroys, and `secret get` writes its bytes straight to stdout, still with no
`Vault.GetSecret` and `Vault.GetSecretVersion` return a trailing newline. Before, the value was copied into ordinary memory that
`*memguard.LockedBuffer`, which every caller destroys, and `secret get` nothing wiped, and `get --version` also wrote it to the debug log.
writes its bytes straight to stdout, still with no trailing newline. - 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
Before, the value was copied into ordinary memory that nothing wiped, use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
and `get --version` also wrote it to the debug log. `make build` calls the new `script/build`, which stamps the version (`VERSION`
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker from the environment, else `git describe`) and the git commit as before.
targets use the local docker daemon, or whatever `DOCKER_HOST` the `build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
environment sets. `make build` calls the new `script/build`, which depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
stamps the version (`VERSION` from the environment, else first.
`git describe`) and the git commit as before. `build`, `clean`, - 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
`install` and `docker-run` are in `.PHONY`; `make install` depends on `.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
`build`. The `vet` target is gone: `script/test` runs `go vet` first. `/secret`, `*.log`, `*.test` and `settings.local.json`
- 2026-10-04: `.gitignore` is the org's standard file, which ignores (https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
`.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus `node_modules`; `.git` stays in the build context for the version stamp.
this repo's `/secret`, `*.log`, `*.test` and `settings.local.json`
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also
leaves out `node_modules`; `.git` stays in the build context for the
version stamp.
- 2026-10-04: `secret init` refuses when the default vault exists, and - 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already `secret vault create NAME` when `NAME` does, with "vault NAME already exists",
exists", before writing anything. The check is in `vault.CreateVault`, before writing anything. The check is in `vault.CreateVault`, which both
which both commands call while holding the state directory lock, so two commands call while holding the state directory lock, so two creates of one
creates of one vault at once cannot both pass the check. Before, either vault at once cannot both pass the check. Before, either command replaced the
command replaced the vault's metadata, passphrase unlocker and vault's metadata, passphrase unlocker and `longterm.age`, so none of its
`longterm.age`, so none of its secrets could be decrypted any more. Both secrets could be decrypted any more. Both commands now ask for the unlocker
commands now ask for the unlocker passphrase before creating the vault, passphrase before creating the vault, so one stopped at that prompt leaves no
so one stopped at that prompt leaves no vault behind. vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time - 2026-10-04: The `internal/cli` tests are back to about their time before the
before the state directory lock state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
(https://git.eeqj.de/sneak/secret/issues/80). The test that each that each changing command waits for the lock releases it as soon as it sees
changing command waits for the lock releases it as soon as it sees the the command waiting there, instead of after a fixed 100 ms. The two vaults
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
with passphrase unlockers that the path and move tests start from are once and copied for each test.
made once and copied for each test. - 2026-10-04: `secret mv` rejects a move whose destination is the source under
- 2026-10-04: `secret mv` rejects a move whose destination is the source another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
under another name, such as `foo` for `Foo` on a case-insensitive macOS default) or a name reached through a symbolic link, before changing
filesystem (the macOS default) or a name reached through a symbolic anything, with or without `--force`, within a vault and between vaults;
link, before changing anything, with or without `--force`, within a before, `--force` removed the destination and so deleted the secret. A rename
vault and between vaults; before, `--force` removed the destination and that changes only letter case works on a case-sensitive filesystem as before.
so deleted the secret. A rename that changes only letter case works on a - 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
case-sensitive filesystem as before. where golangci-lint is a build step rebuilt on every run
- 2026-10-04: Lint runs only in docker: `script/lint` builds (`--no-cache-filter`), so an unchanged tree is linted too; the module download
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on stays cached. `script/bootstrap` no longer installs golangci-lint, and the
every run (`--no-cache-filter`), so an unchanged tree is linted too; `Dockerfile` lint stage calls it directly instead of `make lint`.
the module download stays cached. `script/bootstrap` no longer `golangci-lint config verify` is not run: it fetches its schema live over
installs golangci-lint, and the `Dockerfile` lint stage calls it unpinned HTTPS.
directly instead of `make lint`. `golangci-lint config verify` is not - 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
run: it fetches its schema live over unpinned HTTPS. panics: `GetID()` warns with the unlocker's directory and returns
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID `pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
no longer panics: `GetID()` warns with the unlocker's directory and metadata file cannot be checked for, read or parsed instead of failing, so
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an `secret unlocker list` still lists the others; the listing's ID lookup no
unlocker whose metadata file cannot be checked for, read or parsed longer warns about that directory again.
instead of failing, so `secret unlocker list` still lists the others; - 2026-10-03: `secret mv` rejects a move whose destination is the source
the listing's ID lookup no longer warns about that directory again. (`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
- 2026-10-03: `secret mv` rejects a move whose destination is the defaults to the source name) before changing anything; before, `--force`
source (`mv --force x x`, `mv --force work:x work:`, or an empty removed the destination first and so deleted the secret. Every vault name
destination, which defaults to the source name) before changing given with `vault:` must be one of the existing vaults by exact name, so
anything; before, `--force` removed the destination first and so `work:x work/:x` is rejected instead of being taken for a move between two
deleted the secret. Every vault name given with `vault:` must be one vaults. A move within a named vault no longer makes that vault the current
of the existing vaults by exact name, so `work:x work/:x` is rejected one, whether it succeeds or fails.
instead of being taken for a move between two vaults. A move within a - 2026-10-03: Commands that change the state directory hold one lock (`flock` on
named vault no longer makes that vault the current one, whether it `lock` in the state directory; a mutex on the in-memory test filesystem), so
succeeds or fails. concurrent commands no longer lose versions or race on the current pointers.
- 2026-10-03: Commands that change the state directory hold one lock Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
(`flock` on `lock` in the state directory; a mutex on the in-memory rename), so no file is ever half-written and `current`, `currentvault` and
test filesystem), so concurrent commands no longer lose versions or `current-unlocker` never go missing. New versions, new secrets and cross-vault
race on the current pointers. Every file is written through copies are built in a temporary directory and renamed into place, and removals
`secret.WriteFileAtomic` (temporary file, sync, rename), so no file rename out of the way first, so a version or secret is never half-added and
is ever half-written and `current`, `currentvault` and never half-removed.
`current-unlocker` never go missing. New versions, new secrets and - 2026-10-03: The checks run before changing a vault now stop with an error
cross-vault copies are built in a temporary directory and renamed naming the path and cause when they cannot read what they inspect, instead of
into place, and removals rename out of the way first, so a version reading the failure as "nothing there": the duplicate check before
or secret is never half-added and never half-removed. `unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
- 2026-10-03: The checks run before changing a vault now stop with an the secret count that guards removing the last unlocker and removing a vault,
error naming the path and cause when they cannot read what they and the existing long-term key check before `vault import`.
inspect, instead of reading the failure as "nothing there": the - 2026-10-03: `version rm`, `version promote` and `get --version` accept a
duplicate check before `unlocker add pgp` (an unreadable version only if it is one of the versions `version list` lists for that
`unlockers.d` or unlocker metadata file), the secret count that secret, compared as typed before any path is built (`secret.VersionExists`),
guards removing the last unlocker and removing a vault, and the and touch nothing otherwise. An empty `--version` is rejected instead of
existing long-term key check before `vault import`. meaning the current version. Before, `secret version rm x ../../..` deleted
- 2026-10-03: `version rm`, `version promote` and `get --version` the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
accept a version only if it is one of the versions `version list` version.
lists for that secret, compared as typed before any path is built - 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
(`secret.VersionExists`), and touch nothing otherwise. An empty code after its deferred `memguard.Purge()` has run, and only `main` calls
`--version` is rejected instead of meaning the current version. `os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
Before, `secret version rm x ../../..` deleted the whole vault, buffer before exiting; when the process is in the terminal's foreground
`secret version rm x ..` the secret, and `.` or `""` every version. process group it first restores the terminal settings from startup, so an
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns interrupted passphrase prompt no longer leaves echo off.
the exit code after its deferred `memguard.Purge()` has run, and only - 2026-10-03: Every command that builds a path from a secret name checks the
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's name first with `vault.ValidateSecretName` and touches nothing when it is
handler, which wipes every buffer before exiting; when the process is invalid: `rm`, `mv` (both names, within a vault and between vaults, before
in the terminal's foreground process group it first restores the switching the current vault), `import`, `version list`/`promote`/`rm`,
terminal settings from startup, so an interrupted passphrase prompt no `encrypt` and `decrypt`. The error and `README.md` state the naming rule.
longer leaves echo off. Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
- 2026-10-03: Every command that builds a path from a secret name in it.
checks the name first with `vault.ValidateSecretName` and touches - 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
nothing when it is invalid: `rm`, `mv` (both names, within a vault it is generated into a locked buffer, and the keychain JSON is written and
and between vaults, before switching the current vault), `import`, read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
`version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error Linux) without `encoding/json` holding it; the JSON field names are unchanged.
and `README.md` state the naming rule. Before, `secret rm ..` - 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
deleted the whole vault and `secret rm .` every secret in it. that needs more locked memory than the process can lock, and run every case
- 2026-10-03: The keychain unlocker's age key passphrase stays in under `script/cibuild`. The image stamps the `VERSION` build argument, else
locked memory: it is generated into a locked buffer, and the `git describe --tags --always`, into `Version`, and fails if `.git` is present
keychain JSON is written and read by `KeychainData` code in but yields no version; `make build` stamps `git describe` too, not a fixed
`internal/secret/keychaindata.go` (tested on Linux) without `0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
`encoding/json` holding it; the JSON field names are unchanged.
- 2026-10-02: A plain `docker build .` builds again: the size tests
skip a case that needs more locked memory than the process can
lock, and run every case under `script/cibuild`. The image stamps the
`VERSION` build argument, else `git describe --tags --always`, into
`Version`, and fails if `.git` is present but yields no version;
`make build` stamps `git describe` too, not a fixed `0.1.0`.
`.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy. canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical - 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable `.golangci.yml` (all linters enabled minus the standard disable list, `lll`
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage 88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
image to the tagged v2.12.2 Debian digest; fixed all ~1550 new v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5` `pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
blank lines, sentinel errors for `err113`, `t.Parallel()` where `t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
safe, `_test` package conversions, complexity/`dupl` helper helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
extraction) on branch `golangci-v2.12.2`. Reworked after review: `err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
the `err113` sentinels in `internal/vault`, `internal/secret`, `pkg/bip85` were reshaped so every composed error message is byte-identical to
`internal/cli` and `pkg/bip85` were reshaped so every composed `main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
error message is byte-identical to `main`, and skips an unreadable `unlockers.d` entry with a warning instead of emitting a
`findUnlockerIDByMetadata` now returns an error so `unlocker list` fabricated fallback ID.
skips an unreadable `unlockers.d` entry with a warning instead of
emitting a fabricated fallback ID.
- 2026-08-07: Added `.editorconfig` - 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27). (https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
Makefile shims, README Entrypoints section shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target; - 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`. `.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and - 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the `.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256. `Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret - 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
protection, plus review fixes (stub panics, derivation index, tests, plus review fixes (stub panics, derivation index, tests, README) on branch
README) on branch secure-enclave-unlocker. secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out. - 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with - Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
missing metadata, allow uppercase secret names, fix hardcoded metadata, allow uppercase secret names, fix hardcoded derivation index,
derivation index, validate names in GetSecretVersion against path validate names in GetSecretVersion against path traversal, return errors
traversal, return errors instead of panicking, add Warn() on silent instead of panicking, add Warn() on silent anomalies.
anomalies. - Memory security hardening: LockedBuffer used through encrypt/decrypt paths
- Memory security hardening: LockedBuffer used through encrypt/decrypt (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated removed.
bare-[]byte APIs removed. - Per-secret keypair architecture, vault package refactor, versioning with
- Per-secret keypair architecture, vault package refactor, versioning --version, comprehensive test suite with in-memory filesystem.
with --version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output). - Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands. - Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, - 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
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` `secret version promote` and `secret version rm` (`internal/cli/version.go`;
(`internal/cli/version.go`; was an in-code TODO removed for godox). was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in - Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an `TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
in-code FIXME removed for godox). - CI does not compile, lint or test the files built only with cgo on macOS,
- CI does not compile, lint or test the files built only with cgo on since compiling them needs Apple's SDK:
macOS, since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call `internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse` `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
never run on them, so it would likely find more there than the line run on them, so it would likely find more there than the line lengths. No
lengths. No macOS test runs in CI. A macOS runner would cover all of it macOS test runs in CI. A macOS runner would cover all of it (asked on
(asked on https://git.eeqj.de/sneak/secret/issues/50). https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in - Memory security: age writes an identity's private key out as a string in
ordinary memory, and the copies it makes on the way stay there ordinary memory, and the copies it makes on the way stay there
@@ -481,8 +461,8 @@ https://git.eeqj.de/sneak/secret/milestone/12
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Split oversized CLI functions. - Split oversized CLI functions.
- Cleanups: read statedir from environment or default instead of - Cleanups: read statedir from environment or default instead of passing it
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 (Keychain, GPG), naming
consistency, vault export/import, batch operations, search, secret metadata consistency, vault export/import, batch operations, search, secret metadata
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+45 -28
View File
@@ -1,14 +1,21 @@
# agehd - Deterministic Age Identities from BIP85 # agehd - Deterministic Age Identities from BIP85
The `agehd` package derives deterministic X25519 age identities using BIP85 entropy derivation and a deterministic random number generator (DRNG). This package only supports proper BIP85 sources: BIP39 mnemonics and extended private keys (xprv). The `agehd` package derives deterministic X25519 age identities using BIP85
entropy derivation and a deterministic random number generator (DRNG). This
package only supports proper BIP85 sources: BIP39 mnemonics and extended private
keys (xprv).
## Features ## Features
- **Deterministic key generation**: Same input always produces the same age identity - **Deterministic key generation**: Same input always produces the same age
identity
- **BIP85 compliance**: Uses the BIP85 standard for entropy derivation - **BIP85 compliance**: Uses the BIP85 standard for entropy derivation
- **Multiple key support**: Generate multiple keys from the same source using different indices - **Multiple key support**: Generate multiple keys from the same source using
- **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private keys (xprv) different indices
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid conflicts - **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private
keys (xprv)
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid
conflicts
## Derivation Path ## Derivation Path
@@ -19,6 +26,7 @@ m/83696968'/592366788'/733482323'/n'
``` ```
Where: Where:
- `83696968'` is the BIP85 root path ("bip" in ASCII) - `83696968'` is the BIP85 root path ("bip" in ASCII)
- `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff) - `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff)
- `733482323'` is the application ID (sha256("secret") & 0x7fffffff) - `733482323'` is the application ID (sha256("secret") & 0x7fffffff)
@@ -34,19 +42,19 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive the first identity (index 0) // Derive the first identity (index 0)
identity, err := agehd.DeriveIdentity(mnemonic, 0) identity, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -60,19 +68,19 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
xprv := "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" xprv := "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// Derive the first identity (index 0) from the xprv // Derive the first identity (index 0) from the xprv
identity, err := agehd.DeriveIdentityFromXPRV(xprv, 0) identity, err := agehd.DeriveIdentityFromXPRV(xprv, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -86,20 +94,20 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive multiple identities with different indices // Derive multiple identities with different indices
for i := uint32(0); i < 3; i++ { for i := uint32(0); i < 3; i++ {
identity, err := agehd.DeriveIdentity(mnemonic, i) identity, err := agehd.DeriveIdentity(mnemonic, i)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Identity %d: %s\n", i, identity.Recipient().String()) fmt.Printf("Identity %d: %s\n", i, identity.Recipient().String())
} }
} }
@@ -113,25 +121,25 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// First derive entropy using BIP85 // First derive entropy using BIP85
entropy, err := agehd.DeriveEntropy(mnemonic, 0) entropy, err := agehd.DeriveEntropy(mnemonic, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Then create identity from entropy // Then create identity from entropy
identity, err := agehd.IdentityFromEntropy(entropy) identity, err := agehd.IdentityFromEntropy(entropy)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -151,7 +159,8 @@ Derives a deterministic age identity from a BIP39 mnemonic and index.
#### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)` #### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from an extended private key (xprv) and index. Derives a deterministic age identity from an extended private key (xprv) and
index.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index (0, 1, 2, ...) - `n`: The derivation index (0, 1, 2, ...)
@@ -167,7 +176,8 @@ Derives 32 bytes of entropy from a BIP39 mnemonic and index using BIP85.
#### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)` #### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from an extended private key (xprv) and index using BIP85. Derives 32 bytes of entropy from an extended private key (xprv) and index using
BIP85.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index - `n`: The derivation index
@@ -182,20 +192,27 @@ Converts 32 bytes of entropy into an age X25519 identity.
## Implementation Details ## Implementation Details
1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source 1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive
2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy 64 bytes of entropy from the input source
3. **Key Generation**: 32 bytes are read from the DRNG to generate the age private key 2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for X25519 is seeded with the 64-byte entropy
5. **Bech32 Encoding**: The key is encoded using Bech32 with the "age-secret-key-" prefix 3. **Key Generation**: 32 bytes are read from the DRNG to generate the age
private key
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for
X25519
5. **Bech32 Encoding**: The key is encoded using Bech32 with the
"age-secret-key-" prefix
## Security Considerations ## Security Considerations
- The same mnemonic/xprv and index will always produce the same identity - The same mnemonic/xprv and index will always produce the same identity
- Different indices produce cryptographically independent identities - Different indices produce cryptographically independent identities
- The vendor/application scoping prevents conflicts with other BIP85 applications - The vendor/application scoping prevents conflicts with other BIP85
applications
- The DRNG ensures high-quality randomness for key generation - The DRNG ensures high-quality randomness for key generation
- Private keys are properly clamped for X25519 usage - Private keys are properly clamped for X25519 usage
- Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary passphrases - Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary
passphrases
## Testing ## Testing
@@ -203,4 +220,4 @@ Run the tests with:
```bash ```bash
go test -v ./internal/agehd go test -v ./internal/agehd
``` ```
+17 -10
View File
@@ -1,10 +1,15 @@
# BIP85 - Deterministic Entropy From BIP32 Keychains # BIP85 - Deterministic Entropy From BIP32 Keychains
This package implements [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which allows for deterministic derivation of entropy from a BIP32 master key. This enables a single seed to generate multiple wallet keys, mnemonics, and random values in a fully deterministic way. This package implements
[BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which
allows for deterministic derivation of entropy from a BIP32 master key. This
enables a single seed to generate multiple wallet keys, mnemonics, and random
values in a fully deterministic way.
## Overview ## Overview
BIP85 enables a variety of use cases: BIP85 enables a variety of use cases:
- Generate multiple BIP39 mnemonic seeds from a single master key - Generate multiple BIP39 mnemonic seeds from a single master key
- Derive Bitcoin HD wallet seeds (WIF format) - Derive Bitcoin HD wallet seeds (WIF format)
- Create extended private keys (XPRV) - Create extended private keys (XPRV)
@@ -114,15 +119,16 @@ m/83696968'/{app}'/{parameters}
``` ```
Where: Where:
- `83696968'` is the BIP85 root path (BIP in ASCII) - `83696968'` is the BIP85 root path (BIP in ASCII)
- `{app}'` is the application number: - `{app}'` is the application number:
- `39'` for BIP39 mnemonics - `39'` for BIP39 mnemonics
- `2'` for HD-WIF keys - `2'` for HD-WIF keys
- `32'` for XPRV - `32'` for XPRV
- `128169'` for HEX data - `128169'` for HEX data
- `707764'` for Base64 passwords - `707764'` for Base64 passwords
- `707785'` for Base85 passwords - `707785'` for Base85 passwords
- `828365'` for RSA keys - `828365'` for RSA keys
- `{parameters}` are application-specific parameters - `{parameters}` are application-specific parameters
## Test Vectors ## Test Vectors
@@ -135,7 +141,8 @@ This implementation passes all the test vectors from the BIP85 specification:
- XPRV - XPRV
- SHAKE256 DRNG output - SHAKE256 DRNG output
The implementation is also compatible with the Python reference implementation's test vectors for the DRNG functionality. The implementation is also compatible with the Python reference implementation's
test vectors for the DRNG functionality.
Run the tests with verbose output to see the test vectors and results: Run the tests with verbose output to see the test vectors and results:
@@ -149,4 +156,4 @@ go test -v git.eeqj.de/sneak/secret/pkg/bip85
- [Python Reference Implementation](https://github.com/ethankosakovsky/bip85) - [Python Reference Implementation](https://github.com/ethankosakovsky/bip85)
- [Bitcoin Core](https://github.com/bitcoin/bitcoin) - [Bitcoin Core](https://github.com/bitcoin/bitcoin)
- [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) - [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) - [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
+4 -3
View File
@@ -131,9 +131,10 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# ---- JS / docs repos ---- # ---- JS / docs repos ----
# ensure_node # prettier, pinned in package.json and yarn.lock, formats the markdown
# ensure_yarn ensure_node
# install_js_deps ensure_yarn
install_js_deps
# ---- Go repos ---- # ---- Go repos ----
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
+22 -1
View File
@@ -1,12 +1,33 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes). # script/fmt: format all files (writes): Go with go fmt, markdown with
# prettier.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
go fmt ./... go fmt ./...
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+20
View File
@@ -5,6 +5,25 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then if [ -n "$(gofmt -l .)" ]; then
@@ -12,6 +31,7 @@ main() {
gofmt -l . gofmt -l .
exit 1 exit 1
fi fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+8
View File
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==