diff --git a/.dockerignore b/.dockerignore index 9d848e1..1d67707 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,75 @@ -.git -.gitea +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# The binary `make build` writes. /keyfunc diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index ee73864..ce6d662 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,7 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + # All history and tags, which `git describe --tags` needs. + fetch-depth: 0 - run: script/cibuild diff --git a/.gitignore b/.gitignore index 7e0d436..e078f30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# The built binary -/keyfunc - # OS .DS_Store Thumbs.db @@ -14,8 +11,40 @@ Thumbs.db .vscode/ *.sublime-* -# Environment / secrets -.env -.env.* -*.pem -*.key +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node +node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# The binary `make build` writes. +/keyfunc diff --git a/.golangci.yml b/.golangci.yml index 26b1610..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,14 +10,21 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -28,6 +35,64 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/Dockerfile b/Dockerfile index e48ab65..812c833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# The formatting check, the tests and the build. Linting is not here: -# it runs in its own pinned image, see Dockerfile.lint and script/lint, -# which script/cibuild runs before this file. +# The lint phase, the test phase and a development environment. +# script/lint and script/test each build one phase alone; a plain +# `docker build .` builds both, because the last stage copies a file from +# each. Formatting is checked on the host by script/fmt-check, not here. -# golang:1.26-alpine, 2026-09-07 -FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder - -RUN apk add --no-cache make git +# Lint phase +# golangci/golangci-lint:v2.14.0, 2026-10-04 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -14,13 +14,59 @@ RUN go mod download COPY . . -RUN make fmt-check -RUN make test -RUN make build +RUN golangci-lint run --config .golangci.yml ./... -# alpine:3.23, 2026-09-07 -FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 +# Test phase. -race needs cgo and so a C compiler, which the Debian Go +# image ships. +# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version +# script/bootstrap installs on the host; change both together, and the +# same image in the last stage. +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test -COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc +WORKDIR /src -ENTRYPOINT ["keyfunc"] +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + +# Development environment, and the last stage: a plain `docker build .` +# builds this one. It holds the source tree in /src, what +# script/bootstrap installs, and keyfunc built from that tree on the +# PATH. Nothing is wanted from either phase above; the copies are what +# make BuildKit build them first, so this stage cannot run unless lint +# and test passed. +# golang:1.26.8-trixie, 2026-10-04 +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 + +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null + +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src + +WORKDIR /src + +# script/bootstrap needs only script/ and the dependency manifests. +COPY script/ script/ +COPY go.mod go.sum package.json yarn.lock ./ +RUN script/bootstrap + +COPY . . + +# The version stamped into the binary: the VERSION build argument when one +# is given, otherwise `git describe --tags --always` of the .git in the +# build context. A context that carries .git and still yields no version +# fails the build; with neither, as from a source tarball, it is "dev". +ARG VERSION +RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ + if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ + [ "$version" = unknown ]; }; then \ + echo "no version could be derived although the build context carries .git" >&2; \ + exit 1; \ + fi; \ + make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 635c754..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,15 +0,0 @@ -# The linter, pinned by hash, with this repository linted inside it. -# Building this file is how linting happens; see script/lint. Nothing -# lints on the host, so the answer is the same everywhere. - -# golangci/golangci-lint:v2.12.2, 2026-09-07 -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 - -WORKDIR /src - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN golangci-lint run --timeout 5m diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34edefe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sneak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index af82436..a4129c9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,10 @@ # which needs the version stamped into the binary. VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X 'git.eeqj.de/sneak/keyfunc/internal/cli.Version=$(VERSION)' +LDFLAGS := -s -w -X 'sneak.berlin/go/keyfunc/internal/cli.Version=$(VERSION)' + +# Where script/bootstrap installs Go; it cannot put it on our PATH. +export PATH := $(HOME)/.local/go/bin:$(PATH) .PHONY: default bootstrap setup build test lint fmt fmt-check check \ docker cibuild hooks clean @@ -17,7 +20,7 @@ setup: @script/setup build: - go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc . + go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc ./cmd/keyfunc test: @script/test diff --git a/README.md b/README.md index a5b03d6..4903928 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,82 @@ # keyfunc -`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from -that mnemonic at any time. The same mnemonic, key type and index always give the -same key. +`keyfunc` is an MIT-licensed Go command-line tool by +[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age +identities and child mnemonics, each of which can be recreated from that +mnemonic at any time. The same mnemonic, key type and index always give the same +key. It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and takes the same steps as that repository's `agehd` package. -Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH -keys, `keyfunc age ...` for age identities and for encrypting and decrypting -with them, and `keyfunc mnemonic ...` for child mnemonics derived from the -main one. +Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys, +`keyfunc age ...` for age identities and for encrypting and decrypting with +them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one. + +## Getting Started + +Install with Go: + +``` +go install sneak.berlin/go/keyfunc/cmd/keyfunc@latest +``` + +Or build from a clone and run the binary: + +``` +git clone git@git.eeqj.de:sneak/keyfunc.git +cd keyfunc +make build +./keyfunc --version +``` + +`make build` produces `./keyfunc`. Every deriving command needs a mnemonic; see +[Giving it the mnemonic](#giving-it-the-mnemonic) for where it is read from, +then for example: + +``` +./keyfunc ssh pub -n 0 --mnemonic-command 'secret get foo' +``` + +## Rationale + +A key you can derive again never has to be backed up. One mnemonic, kept safe +once, stands behind every key this tool produces: lose a laptop and the SSH key, +the age identity and any child mnemonic on it come back from the mnemonic alone, +at the same index, byte for byte. Nothing else has to be written down, copied +between machines, or stored in a secret manager, because it can always be +derived again. + +## Design + +The entry point is a thin `cmd/keyfunc/main.go` (what `make build` builds) that +calls into `internal/`. The packages there are: + +- `internal/derive` turns a mnemonic into the 32 bytes a key is made from: it + walks BIP-39 seed, BIP-32 master key and BIP-85 entropy, and holds the shared + constants (the byte count and the largest key index). +- `internal/mnemonic` finds the mnemonic to work from — a command, an + environment variable, or a terminal prompt — and refuses one that fails the + BIP-39 checksum. +- `internal/sshkey` turns the derived bytes into an ed25519 SSH key + (`sshkey.go`) and serves that key from an in-process SSH agent on a private + unix socket, keeping it out of any file (`agent.go`). +- `internal/agekey` turns the derived bytes into an age identity and encrypts + and decrypts with it. +- `internal/childmnemonic` derives a child mnemonic from the main one using + BIP-85's own mnemonic application. +- `internal/cli` builds the cobra command tree and runs it. Under it, + `cli/options` holds the flags every command shares, `cli/signals` catches + SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and + `cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups. +- `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0, + trimmed to what keyfunc uses. + +### Adding a key type + +Adding a key type is one package under `internal/` that turns the 32 derived +bytes into that type's key, plus one cobra subcommand under `internal/cli/` that +groups its commands. ## Derivation @@ -42,26 +108,32 @@ The mnemonic itself is never a command-line argument. It is looked for in this order; the first one found wins: 1. `--mnemonic-command `: a shell command, run with `sh -c`, whose - standard output is the mnemonic. Example: `--mnemonic-command 'secret get - foo'`. Whitespace around the output is dropped. If the command exits with a - non-zero status, the tool prints its standard error and exits with status 1. -2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell - command held in the environment. + standard output is the mnemonic. Example: + `--mnemonic-command 'secret get foo'`. If the command exits with a non-zero + status, the tool prints its standard error and exits with status 1. +2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command + held in the environment. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. 4. A prompt on the terminal with echo turned off. If none of these is available and standard input is not a terminal, the tool refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is -refused with a message saying so. +refused with a message saying so. Keys are derived from the mnemonic's words +joined by single spaces, whatever whitespace is around or between them, so one +word per line, tabs or extra spaces give the same keys. + +`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the +environment before the system `ssh` (`keyfunc ssh to`) and `sftp` +(`keyfunc ssh install`) are started, so the mnemonic is never handed on to them. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. -`keyfunc --version` prints the version set at build time. +`keyfunc --version` prints the version. `make build` stamps it; a binary +installed with `go install` reports the module version instead. ## SSH keys: `keyfunc ssh` -Only ed25519 keys are produced. The application number is `838372`, so the -path is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 -seed. +Only ed25519 keys are produced. The application number is `838372`, so the path +is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 seed. Test vector, mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: @@ -88,17 +160,58 @@ Prints the unencrypted private key in OpenSSH format (the and nothing else, so it can be redirected into a file. The key's comment is the same as for `pub`. -### `keyfunc ssh install <[user@]host> [-- ssh options...]` +### `keyfunc ssh install <[user@]host> [-- sftp options...]` -Runs the system `ssh` to the host and, on the host: +Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run +on the host: the file is fetched, changed here, and written back with the system +`sftp` client in batch mode. -- creates `~/.ssh` with mode `0700` if it is missing; -- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing; -- appends the `pub` line only if an identical line is not already there. +The first connection lists `~/.ssh`, then `~/.ssh/.`, and then fetches +`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp` +reported `~/.ssh` itself as not being there, or both listings came up and the +file was not found. Any other outcome of that connection fails the run — a +`~/.ssh` that is there but cannot be read or entered, an `authorized_keys` that +is there but cannot be read, or a connection that did not come up — and the tool +prints what `sftp` said and exits with status 1 without writing anything, rather +than put a file back holding the new key alone. The listings are what tell a +missing directory from one shut to the user, which `sftp` reports on a fetch the +same way: one that cannot be read fails the first listing, and one that can be +read but not entered fails the second, after which the tool says that `~/.ssh` +cannot be entered. The wording of a missing file elsewhere does not count +either, since `ssh` writes `No such file or directory` about an `-i` it cannot +find on a session that then authenticates through the agent. An +`authorized_keys` that the first listing shows to be a symlink is refused and +left as it is, since the rename below would replace the link itself and the file +it points at would never get the key. If an identical line is already in the +file, the tool prints `already present` and connects no further. Otherwise the +line is added (after a newline, if the file did not end with one) and a second +connection: -It then prints `added` or `already present`. How this `ssh` connection -authenticates is up to the user's normal `ssh` setup (existing keys, agent, -password). Anything after `--` is passed to `ssh` unchanged. +- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection + found none; a `~/.ssh` that was already there keeps the mode it had; +- uploads the new file as `~/.ssh/authorized_keys.keyfunc-` and sets it + to mode `0600`; +- renames that file over `~/.ssh/authorized_keys`. + +The tool then prints `added`. So a run that adds a line connects twice. The +rename is the step that either happens or does not: the file on the host is +never half-written. `sftp` does it in one step against servers that offer +OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server without +it may refuse to rename onto a file that is already there. + +If a step fails, the tool prints what `sftp` said, removes nothing, and exits +with status 1. It names the uploaded file only when the step that failed was the +upload or one after it, which is where a file of that name can be on the host; a +failure before the upload names none. Everything `sftp` writes goes to standard +error, so the tool's own standard output is only `added` or `already present`. + +Anything after `--` is passed to `sftp` unchanged, which is where the port goes +(`-P 2222`, not `-p`). How the connection authenticates is up to the user's +normal `ssh` setup, except that batch mode does not prompt: a key or an agent +has to do it, not a typed password. Nor does it ask whether to trust a host key +it has not seen, so the host has to be in `known_hosts` already, or the run +fails with `Host key verification failed`. Connect to the host once with `ssh` +first, or pass `-o StrictHostKeyChecking=accept-new` after `--`. ### `keyfunc ssh to [ssh arguments...]` @@ -106,15 +219,26 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix socket in a new private `0700` temporary directory, then runs the system `ssh` with `-o IdentityAgent=` followed by the host and all remaining arguments unchanged. The tool exits with `ssh`'s exit status and removes the -socket and directory on the way out. The private key is never written to disk. +socket and directory on the way out. The private key is never written to disk. A +SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory, +and the tool then exits with status 1 unless `ssh` reported one of its own. ## age identities: `keyfunc age` The application number is `657169`, path `m/83696968'/657169'/'`. The 32 -bytes from step 4 are clamped as X25519 requires and become an age identity, -the same steps `sneak/secret` takes in its `agehd` package. `secret` derives at -a vendor-specific path today; for its keys to equal this tool's it moves to -this path, which is a change in `secret`, not here. +bytes from step 4 are clamped as X25519 requires and become an age identity, the +same steps `sneak/secret` takes in its `agehd` package. `secret` derives at a +vendor-specific path today; for its keys to equal this tool's it moves to this +path, which is a change in `secret`, not here. + +Test vectors, mnemonic +`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: + +``` +recipient index 0: age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysewlj68kmx5n67e3nsg2mftq +recipient index 1: age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfgqm7hmakqhvgyy4q5p3w7j +identity index 0: AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD7527T70JDHGDJ7AMNF3XT44S00JFU5 +``` ### `keyfunc age pub` @@ -131,50 +255,132 @@ identity's own recipient, plus any given with `--to`, so the same mnemonic can always decrypt what it encrypted. Output goes to `-o` or standard output; `--armor` writes the text form. Nothing is written except the output. +A `-o` path that is the same file as the tool's own standard output or standard +error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that +stream, as leaving out `-o` writes to standard output; the file the stream is +redirected to is written as the redirect says and never replaced, so with `>>` +the output follows what the file already held. Otherwise, a regular file already +at the `-o` path is replaced, and the new file has mode `0600`. A symlink there +is followed, and what it points at is treated the same way, so the link keeps +pointing where it did; a symlink that points at nothing is refused. A named pipe +or a device, such as `/dev/null`, is written to directly. + ### `keyfunc age decrypt [-n N] [-o ] []` Decrypts the file (or standard input) with the derived identity. Output goes to -`-o` or standard output. If the identity is not one of the recipients, the tool -says so and exits with status 1. +`-o`, which is treated as for `encrypt`, or standard output. If the identity is +not one of the recipients, the tool says so and exits with status 1. ## Derived mnemonics: `keyfunc mnemonic` ### `keyfunc mnemonic [-n N] [--words 12|18|24]` Prints a child mnemonic derived from the main one, using BIP-85's own mnemonic -application (number `39`, English, path -`m/83696968'/39'/0'/'/'`, entropy taken as the specification says, -not through step 4). Default 12 words. A child mnemonic is a full mnemonic in -its own right: it can seed another `keyfunc`, another wallet, or `secret`, and -it never has to be written down, since it can be derived again. +application (number `39`, English, path `m/83696968'/39'/0'/'/'`, +entropy taken as the specification says, not through step 4). Default 12 words. +A child mnemonic is a full mnemonic in its own right: it can seed another +`keyfunc`, another wallet, or `secret`, and it never has to be written down, +since it can be derived again. -## Adding a key type +Test vector, mnemonic +`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: -Adding a key type is one package under `internal/` that turns the 32 derived -bytes into that type's key, plus one cobra subcommand under `internal/cli/` that -groups its commands. +``` +index 0: prosper short ramp prepare exchange stove life snack client enough purpose fold +``` + +The child-mnemonic step is also checked against BIP-85's own published vectors. +Those start from the specification's master key +`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb` +rather than from a mnemonic, so they cannot be given to `keyfunc`; at key index +0 the 12-word English child mnemonic of that key is: + +``` +girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose +``` ## Errors -Errors go to standard error and the exit status is 1, except for `ssh to`, -which passes through `ssh`'s own exit status. +Errors go to standard error and the exit status is 1, except for `ssh to`, which +passes through `ssh`'s own exit status. -## Building and running +SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, +with the status a shell gives a program killed by that signal (130 for SIGINT). +While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a +regular one, the signal makes it remove the unfinished file, leave a file +already at the named path as it was, and exit with status 1. That holds for a +signal that has reached `keyfunc` when its input ends; a later one leaves the +whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and +on Linux `keyfunc` sees the signal first, though no system promises that. A +named pipe or a device at the `-o` path, or a path that is the same file as +standard output or standard error, is written to directly, and the signal ends +the tool there as it ends any other command. While `ssh to` or `ssh install` has +`ssh` or `sftp` running, the signal ends that program instead, the tool removes +its agent socket or working files, and it exits with status 1, or for `ssh to` +with `ssh`'s own status if `ssh` reported one. -``` -make build # produces ./keyfunc -make check # fmt-check, lint (golangci-lint) and tests -``` +## Entrypoints -Examples: +The repo adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard: most Makefile targets are thin shims over an executable in `script/` +(`build` and `clean` are the exceptions). -``` -keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo' -keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3 -keyfunc ssh install -n 3 user@example.com -keyfunc ssh to -n 3 user@example.com uptime -keyfunc age pub -n 0 -keyfunc age encrypt -n 0 --armor -o notes.age notes.txt -keyfunc age decrypt -n 0 notes.age -keyfunc mnemonic -n 1 --words 24 -``` +- `script/bootstrap` installs, idempotently, everything needed to build and + develop apart from Docker, which it only warns about when it is missing, and + the linter, which only runs inside Docker. In this order: + - git and make from nix, apt, brew or apk, and from there too curl and bash + when a later step needs them; + - Go at the version the `Dockerfile`'s Go image carries, from the official + release archive at go.dev (checked against a sha256 in the script) into + `~/.local/go`, unless the `go` first on the `PATH` already is that + version; then the Go modules. `script/bootstrap` itself, `script/fmt`, + `script/fmt-check`, `script/precommit` and the `Makefile` put + `~/.local/go/bin` first on their `PATH`, so they use that Go; + - node: an installed one is used as it is, otherwise a pinned one is + installed through nvm, which, when it is missing, comes from its release + archive, checked against a sha256; + - yarn: an installed one is used as it is, otherwise the pinned version + through corepack, or through npm where there is no corepack; + - the pinned prettier, through yarn. +- `script/setup` prepares a fresh clone: it runs `bootstrap`, then installs the + git pre-commit hook. +- `script/projectname` prints the project name; other scripts call it so they + stay identical across repos. +- `script/test` builds the `test` phase of the `Dockerfile` alone, uncached: the + test suite runs with the race detector inside the build, rerunning verbosely + if a test fails. +- `script/lint` builds the `lint` phase of the `Dockerfile` alone, uncached: the + linter, pinned by hash, runs inside the build, so a complaint fails it and + leaves no container behind. +- `script/fmt` formats in place: the Go source with `go fmt`, then every + Markdown file with prettier (four-space indents, prose wrapped at 80 columns). +- `script/fmt-check` checks the same files the same way without writing, failing + if anything is unformatted. Both need the node, yarn and prettier that + `bootstrap` installs. +- `script/check` runs `test`, `lint` and `fmt-check` and changes no files. +- `script/docker` builds the Docker image, uncached, tagged with the project + name and stamped with the version `git describe` gives on the host. The image + cannot be built unless the `lint` and `test` phases pass, so a plain + `docker build .` runs them too. The image is a development environment, not a + runtime image: the Debian Go image with what `script/bootstrap` installs, the + source tree in `/src`, and `keyfunc` built from it on the `PATH`. + `docker run --rm -it keyfunc` opens a shell in it. +- `script/cibuild` is the CI build the Gitea workflow calls: it runs + `bootstrap`, then `check`, then builds the image as `script/docker` does. +- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and + `go fmt`, failing if `go.mod` or `go.sum` changed, then `check`. +- `script/install-precommit` installs the git pre-commit hook that runs + `script/precommit`. + +## TODO + +No issues are open. + +## License + +MIT. The full text is in [`LICENSE`](LICENSE). + +## Author + +[@sneak](https://sneak.berlin). diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index fad388c..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-19 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -89,87 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. Dockerfiles install development - prerequisites by running `script/bootstrap` rather than duplicating installs - inline; COPY `script/` and the dependency manifests (`package.json` + - `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap - layer stays cached until dependencies change. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. - If the project uses `//go:embed` directives that reference build artifacts - (e.g. a web frontend compiled in a separate stage), the lint stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -193,15 +315,17 @@ style conventions are in separate documents: suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test - invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately - sits above the hard cap so that it catches a genuinely hung test rather than a - merely slow one. + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`make test` should use the conditional verbose rerun pattern.** Run tests - without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to - show full output. This keeps CI logs and `docker build` output clean on - success (just package/suite summaries) while providing full diagnostic detail - on failure (every test case, every assertion). The general shell pattern: +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -214,11 +338,26 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 90s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + Python example: ```makefile @@ -244,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -269,9 +482,50 @@ style conventions are in separate documents: byte-identical, so that no repo can quietly loosen its own linting. Linter configuration changes are made to the canonical copy in the `prompts` repo and reach consuming repos by re-vendoring; an agent may open a PR against - canonical, which only the user merges. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), installed commit-pinned via - `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -385,10 +639,10 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose body is a single call into `internal/` or `pkg/`, no project logic in @@ -419,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/main.go b/cmd/keyfunc/main.go similarity index 75% rename from main.go rename to cmd/keyfunc/main.go index 90d87cc..a3b2c11 100644 --- a/main.go +++ b/cmd/keyfunc/main.go @@ -4,7 +4,7 @@ package main import ( "os" - "git.eeqj.de/sneak/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli" ) func main() { diff --git a/go.mod b/go.mod index 4a2f2ce..107494e 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,26 @@ -module git.eeqj.de/sneak/keyfunc +module sneak.berlin/go/keyfunc -go 1.26 +go 1.26.0 require ( - filippo.io/age v1.2.1 + filippo.io/age v1.3.2 git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd - github.com/btcsuite/btcd v0.24.2 - github.com/btcsuite/btcd/btcutil v1.1.6 - github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.8.4 - github.com/tyler-smith/go-bip39 v1.1.0 - golang.org/x/crypto v0.38.0 - golang.org/x/term v0.32.0 + github.com/btcsuite/btcd v0.25.0 + github.com/btcsuite/btcd/btcutil v1.2.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 ) require ( - github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + filippo.io/hpke v0.4.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 4915728..5c2cbff 100644 --- a/go.sum +++ b/go.sum @@ -1,136 +1,44 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= -filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= -filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= +filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= -github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= -github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= -github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= -github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= -github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= -github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= -github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= -github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= -github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= -github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= -github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= -github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= -github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= -github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= -github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= +github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= +github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= +github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= +github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= -github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= -golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agekey/agekey_test.go b/internal/agekey/agekey_test.go index 376e3ab..fd488d0 100644 --- a/internal/agekey/agekey_test.go +++ b/internal/agekey/agekey_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/derive" ) // The recipients the example mnemonic produces at the first two diff --git a/internal/bip39/LICENSE b/internal/bip39/LICENSE new file mode 100644 index 0000000..4dae82d --- /dev/null +++ b/internal/bip39/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2018 Tyler Smith and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/bip39/bip39.go b/internal/bip39/bip39.go new file mode 100644 index 0000000..dc748b5 --- /dev/null +++ b/internal/bip39/bip39.go @@ -0,0 +1,268 @@ +// Package bip39 is the Golang implementation of the BIP39 spec. +// +// The official BIP39 spec can be found at +// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki +// +// It is a copy of github.com/tyler-smith/go-bip39 v1.1.0, trimmed to what +// keyfunc uses. +// +//nolint:mnd // the numbers are BIP-39's own, written as upstream writes them +package bip39 + +import ( + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "math/big" + "strings" + + "golang.org/x/crypto/pbkdf2" +) + +var ( + // ErrInvalidMnemonic is returned when trying to use a malformed mnemonic. + ErrInvalidMnemonic = errors.New("invalid mnenomic") + + // ErrEntropyLengthInvalid is returned when trying to use an entropy set with + // an invalid size. + ErrEntropyLengthInvalid = errors.New( + "entropy length must be [128, 256] and a multiple of 32", + ) + + // ErrChecksumIncorrect is returned when entropy has the incorrect checksum. + ErrChecksumIncorrect = errors.New("checksum incorrect") +) + +// EntropyFromMnemonic takes a mnemonic generated by this library, +// and returns the input entropy used to generate the given mnemonic. +// An error is returned if the given mnemonic is invalid. +func EntropyFromMnemonic(mnemonic string) ([]byte, error) { + mnemonicSlice, isValid := splitMnemonicWords(mnemonic) + if !isValid { + return nil, ErrInvalidMnemonic + } + + // Some bitwise operands for working with big.Ints + shift11BitsMask := big.NewInt(2048) + bigOne := big.NewInt(1) + + // used to isolate the checksum bits from the entropy+checksum byte array + wordLengthChecksumMasksMapping := map[int]*big.Int{ + 12: big.NewInt(15), + 15: big.NewInt(31), + 18: big.NewInt(63), + 21: big.NewInt(127), + 24: big.NewInt(255), + } + // used to use only the desired x of 8 available checksum bits. + // 256 bit (word length 24) requires all 8 bits of the checksum, + // and thus no shifting is needed for it (we would get a divByZero crash if we did) + wordLengthChecksumShiftMapping := map[int]*big.Int{ + 12: big.NewInt(16), + 15: big.NewInt(8), + 18: big.NewInt(4), + 21: big.NewInt(2), + } + + // wordMap is a reverse lookup map for the word list + wordMap := map[string]int{} + for i, v := range English() { + wordMap[v] = i + } + + // Decode the words into a big.Int. + b := big.NewInt(0) + + for _, v := range mnemonicSlice { + index, found := wordMap[v] + if !found { + return nil, fmt.Errorf( + "%w: word `%v` not found in reverse map", ErrInvalidMnemonic, v, + ) + } + + var wordBytes [2]byte + + //nolint:gosec // the index of a word in the list is below 2048 + binary.BigEndian.PutUint16(wordBytes[:], uint16(index)) + + b = b.Mul(b, shift11BitsMask) + b = b.Or(b, big.NewInt(0).SetBytes(wordBytes[:])) + } + + // Build and add the checksum to the big.Int. + checksum := big.NewInt(0) + checksumMask := wordLengthChecksumMasksMapping[len(mnemonicSlice)] + checksum = checksum.And(b, checksumMask) + + b.Div(b, big.NewInt(0).Add(checksumMask, bigOne)) + + // The entropy is the underlying bytes of the big.Int. Any upper bytes of + // all 0's are not returned so we pad the beginning of the slice with empty + // bytes if necessary. + entropy := b.Bytes() + entropy = padByteSlice(entropy, len(mnemonicSlice)/3*4) + + // Generate the checksum and compare with the one we got from the mneomnic. + entropyChecksumBytes := computeChecksum(entropy) + entropyChecksum := big.NewInt(int64(entropyChecksumBytes[0])) + + if l := len(mnemonicSlice); l != 24 { + checksumShift := wordLengthChecksumShiftMapping[l] + entropyChecksum.Div(entropyChecksum, checksumShift) + } + + if checksum.Cmp(entropyChecksum) != 0 { + return nil, ErrChecksumIncorrect + } + + return entropy, nil +} + +// NewMnemonic will return a string consisting of the mnemonic words for +// the given entropy. +// If the provide entropy is invalid, an error will be returned. +func NewMnemonic(entropy []byte) (string, error) { + // Compute some lengths for convenience. + entropyBitLength := len(entropy) * 8 + checksumBitLength := entropyBitLength / 32 + sentenceLength := (entropyBitLength + checksumBitLength) / 11 + + // Validate that the requested size is supported. + err := validateEntropyBitSize(entropyBitLength) + if err != nil { + return "", err + } + + // Some bitwise operands for working with big.Ints + last11BitsMask := big.NewInt(2047) + shift11BitsMask := big.NewInt(2048) + + // wordList is the set of words to use + wordList := English() + + // Add checksum to entropy. + entropy = addChecksum(entropy) + + // Break entropy up into sentenceLength chunks of 11 bits. + // For each word AND mask the rightmost 11 bits and find the word at that index. + // Then bitshift entropy 11 bits right and repeat. + // Add to the last empty slot so we can work with LSBs instead of MSB. + + // Entropy as an int so we can bitmask without worrying about bytes slices. + entropyInt := new(big.Int).SetBytes(entropy) + + // Slice to hold words in. + words := make([]string, sentenceLength) + + // Throw away big.Int for AND masking. + word := big.NewInt(0) + + for i := sentenceLength - 1; i >= 0; i-- { + // Get 11 right most bits and bitshift 11 to the right for next time. + word.And(entropyInt, last11BitsMask) + entropyInt.Div(entropyInt, shift11BitsMask) + + // Get the bytes representing the 11 bits as a 2 byte slice. + wordBytes := padByteSlice(word.Bytes(), 2) + + // Convert bytes to an index and add that word to the list. + words[i] = wordList[binary.BigEndian.Uint16(wordBytes)] + } + + return strings.Join(words, " "), nil +} + +// NewSeed creates a hashed seed output given a provided string and password. +// No checking is performed to validate that the string provided is a valid mnemonic. +func NewSeed(mnemonic string, password string) []byte { + return pbkdf2.Key([]byte(mnemonic), []byte("mnemonic"+password), 2048, 64, sha512.New) +} + +// IsMnemonicValid attempts to verify that the provided mnemonic is valid. +// Validity is determined by both the number of words being appropriate, +// and that all the words in the mnemonic are present in the word list. +func IsMnemonicValid(mnemonic string) bool { + _, err := EntropyFromMnemonic(mnemonic) + + return err == nil +} + +// Appends to data the first (len(data) / 32)bits of the result of sha256(data) +// Currently only supports data up to 32 bytes +func addChecksum(data []byte) []byte { + // Some bitwise operands for working with big.Ints + bigOne := big.NewInt(1) + bigTwo := big.NewInt(2) + + // Get first byte of sha256 + hash := computeChecksum(data) + firstChecksumByte := hash[0] + + // len() is in bytes so we divide by 4 + checksumBitLength := uint(len(data) / 4) + + // For each bit of check sum we want we shift the data one the left + // and then set the (new) right most bit equal to checksum bit at that index + // staring from the left + dataBigInt := new(big.Int).SetBytes(data) + for i := range checksumBitLength { + // Bitshift 1 left + dataBigInt.Mul(dataBigInt, bigTwo) + + // Set rightmost bit if leftmost checksum bit is set + if firstChecksumByte&(1<<(7-i)) > 0 { + dataBigInt.Or(dataBigInt, bigOne) + } + } + + return dataBigInt.Bytes() +} + +func computeChecksum(data []byte) []byte { + hasher := sha256.New() + hasher.Write(data) + + return hasher.Sum(nil) +} + +// validateEntropyBitSize ensures that entropy is the correct size for being a +// mnemonic. +func validateEntropyBitSize(bitSize int) error { + if (bitSize%32) != 0 || bitSize < 128 || bitSize > 256 { + return ErrEntropyLengthInvalid + } + + return nil +} + +// padByteSlice returns a byte slice of the given size with contents of the +// given slice left padded and any empty spaces filled with 0's. +func padByteSlice(slice []byte, length int) []byte { + offset := length - len(slice) + if offset <= 0 { + return slice + } + + newSlice := make([]byte, length) + copy(newSlice[offset:], slice) + + return newSlice +} + +func splitMnemonicWords(mnemonic string) ([]string, bool) { + // Create a list of all the words in the mnemonic sentence + words := strings.Fields(mnemonic) + + // Get num of words + numOfWords := len(words) + + // The number of words should be 12, 15, 18, 21 or 24 + if numOfWords%3 != 0 || numOfWords < 12 || numOfWords > 24 { + return nil, false + } + + return words, true +} diff --git a/internal/bip39/bip39_internal_test.go b/internal/bip39/bip39_internal_test.go new file mode 100644 index 0000000..a4a1e19 --- /dev/null +++ b/internal/bip39/bip39_internal_test.go @@ -0,0 +1,442 @@ +package bip39 + +import ( + "crypto/rand" + "encoding/hex" + "testing" +) + +type vector struct { + entropy string + mnemonic string + seed string +} + +func TestNewMnemonic(t *testing.T) { + t.Parallel() + + for _, vector := range testVectors() { + entropy, err := hex.DecodeString(vector.entropy) + assertNil(t, err) + + mnemonic, err := NewMnemonic(entropy) + assertNil(t, err) + assertEqualString(t, vector.mnemonic, mnemonic) + + seed := NewSeed(mnemonic, "TREZOR") + assertEqualString(t, vector.seed, hex.EncodeToString(seed)) + } +} + +func TestNewMnemonicInvalidEntropy(t *testing.T) { + t.Parallel() + + _, err := NewMnemonic([]byte{}) + assertNotNil(t, err) +} + +func TestIsMnemonicValid(t *testing.T) { + t.Parallel() + + for _, vector := range badMnemonicSentences() { + assertFalse(t, IsMnemonicValid(vector.mnemonic)) + } + + for _, vector := range testVectors() { + assertTrue(t, IsMnemonicValid(vector.mnemonic)) + } +} + +func TestPadByteSlice(t *testing.T) { + t.Parallel() + + assertEqualByteSlices(t, []byte{0}, padByteSlice([]byte{}, 1)) + assertEqualByteSlices(t, []byte{0, 1}, padByteSlice([]byte{1}, 2)) + assertEqualByteSlices(t, []byte{1, 1}, padByteSlice([]byte{1, 1}, 2)) + assertEqualByteSlices(t, []byte{1, 1, 1}, padByteSlice([]byte{1, 1, 1}, 2)) +} + +//nolint:funlen // the test vectors, kept as upstream wrote them +func TestMnemonicToByteArrayForZeroLeadingSeeds(t *testing.T) { + t.Parallel() + + ms := []string{ + "00000000000000000000000000000000", + "00a84c51041d49acca66e6160c1fa999", + "00ca45df1673c76537a2020bfed1dafd", + "0019d5871c7b81fd83d474ef1c1e1dae", + "00dcb021afb35ffcdd1d032d2056fc86", + "0062be7bd09a27288b6cf0eb565ec739", + "00dc705b5efa0adf25b9734226ba60d4", + "0017747418d54c6003fa64fade83374b", + "000d44d3ee7c3dfa45e608c65384431b", + "008241c1ef976b0323061affe5bf24b9", + "00a6aec77e4d16bea80b50a34991aaba", + "0011527b8c6ddecb9d0c20beccdeb58d", + "001c938c503c8f5a2bba2248ff621546", + "0002f90aaf7a8327698f0031b6317c36", + "00bff43071ed7e07f77b14f615993bac", + "00da143e00ef17fc63b6fb22dcc2c326", + "00ffc6764fb32a354cab1a3ddefb015d", + "0062ef47e0985e8953f24760b7598cdd", + "003bf9765064f71d304908d906c065f5", + "00993851503471439d154b3613947474", + "007ad0ffe9eae753a483a76af06dfa67", + "00091824db9ec19e663bee51d64c83cc", + "00f48ac621f7e3cb39b2012ac3121543", + "0072917415cdca24dfa66c4a92c885b4", + "0027ced2b279ea8a91d29364487cdbf4", + "00b9c0d37fb10ba272e55842ad812583", + "004b3d0d2b9285946c687a5350479c8c", + "00c7c12a37d3a7f8c1532b17c89b724c", + "00f400c5545f06ae17ad00f3041e4e26", + "001e290be10df4d209f247ac5878662b", + "00bf0f74568e582a7dd1ee64f792ec8b", + "00d2e43ecde6b72b847db1539ed89e23", + "00cecba6678505bb7bfec8ed307251f6", + "000aeed1a9edcbb4bc88f610d3ce84eb", + "00d06206aadfc25c2b21805d283f15ae", + "00a31789a2ab2d54f8fadd5331010287", + "003493c5f520e8d5c0483e895a121dc9", + "004706112800b76001ece2e268bc830e", + "00ab31e28bb5305be56e38337dbfa486", + "006872fe85df6b0fa945248e6f9379d1", + "00717e5e375da6934e3cfdf57edaf3bd", + "007f1b46e7b9c4c76e77c434b9bccd6b", + "00dc93735aa35def3b9a2ff676560205", + "002cd5dcd881a49c7b87714c6a570a76", + "0013b5af9e13fac87e0c505686cfb6bf", + "007ab1ec9526b0bc04b64ae65fd42631", + "00abb4e11d8385c1cca905a6a65e9144", + "00574fc62a0501ad8afada2e246708c3", + "005207e0a815bb2da6b4c35ec1f2bf52", + "00f3460f136fb9700080099cbd62bc18", + "007a591f204c03ca7b93981237112526", + "00cfe0befd428f8e5f83a5bfc801472e", + "00987551ac7a879bf0c09b8bc474d9af", + "00cadd3ce3d78e49fbc933a85682df3f", + "00bfbf2e346c855ccc360d03281455a1", + "004cdf55d429d028f715544ce22d4f31", + "0075c84a7d15e0ac85e1e41025eed23b", + "00807dddd61f71725d336cab844d2cb5", + "00422f21b77fe20e367467ed98c18410", + "00b44d0ac622907119c626c850a462fd", + "00363f5e7f22fc49f3cd662a28956563", + "000fe5837e68397bbf58db9f221bdc4e", + "0056af33835c888ef0c22599686445d3", + "00790a8647fd3dfb38b7e2b6f578f2c6", + "00da8d9009675cb7beec930e263014fb", + "00d4b384540a5bb54aa760edaa4fb2fe", + "00be9b1479ed680fdd5d91a41eb926d0", + "009182347502af97077c40a6e74b4b5c", + "00f5c90ee1c67fa77fd821f8e9fab4f1", + "005568f9a2dd6b0c0cc2f5ba3d9cac38", + "008b481f8678577d9cf6aa3f6cd6056b", + "00c4323ece5e4fe3b6cd4c5c932931af", + "009791f7550c3798c5a214cb2d0ea773", + "008a7baab22481f0ad8167dd9f90d55c", + "00f0e601519aafdc8ff94975e64c946d", + "0083b61e0daa9219df59d697c270cd31", + } + + for _, m := range ms { + seed, _ := hex.DecodeString(m) + + mnemonic, err := NewMnemonic(seed) + if err != nil { + t.Errorf("%v", err) + } + + _, err = EntropyFromMnemonic(mnemonic) + if err != nil { + t.Errorf("Failed for %x - %v", seed, mnemonic) + } + } +} + +func TestEntropyFromMnemonic128(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 128) +} + +func TestEntropyFromMnemonic160(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 160) +} + +func TestEntropyFromMnemonic192(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 192) +} + +func TestEntropyFromMnemonic224(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 224) +} + +func TestEntropyFromMnemonic256(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 256) +} + +//nolint:dupword,lll // the test vector, kept as upstream wrote it +func TestEntropyFromMnemonicInvalidChecksum(t *testing.T) { + t.Parallel() + + _, err := EntropyFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon yellow") + assertEqual(t, ErrChecksumIncorrect, err) +} + +//nolint:dupword // the test vectors, kept as upstream wrote them +func TestEntropyFromMnemonicInvalidMnemonicSize(t *testing.T) { + t.Parallel() + + for _, mnemonic := range []string{ + "a a a a a a a a a a a a a a a a a a a a a a a a a", // Too many words + "a", // Too few + "a a a a a a a a a a a a a a", // Not multiple of 3 + } { + _, err := EntropyFromMnemonic(mnemonic) + assertEqual(t, ErrInvalidMnemonic, err) + } +} + +func testEntropyFromMnemonic(t *testing.T, bitSize int) { + t.Helper() + + for range 512 { + expectedEntropy := make([]byte, bitSize/8) + _, err := rand.Read(expectedEntropy) + assertNil(t, err) + assertTrue(t, len(expectedEntropy) != 0) + + mnemonic, err := NewMnemonic(expectedEntropy) + assertNil(t, err) + assertTrue(t, len(mnemonic) != 0) + + actualEntropy, err := EntropyFromMnemonic(mnemonic) + assertNil(t, err) + assertEqualByteSlices(t, expectedEntropy, actualEntropy) + } +} + +//nolint:dupword,funlen,lll // the BIP-39 test vectors, kept as upstream wrote them +func testVectors() []vector { + return []vector{ + { + entropy: "00000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + seed: "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow", + seed: "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607", + }, + { + entropy: "80808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + seed: "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8", + }, + { + entropy: "ffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + seed: "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069", + }, + { + entropy: "000000000000000000000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent", + seed: "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will", + seed: "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd", + }, + { + entropy: "808080808080808080808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always", + seed: "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65", + }, + { + entropy: "ffffffffffffffffffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + seed: "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528", + }, + { + entropy: "0000000000000000000000000000000000000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + seed: "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title", + seed: "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87", + }, + { + entropy: "8080808080808080808080808080808080808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless", + seed: "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f", + }, + { + entropy: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + seed: "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad", + }, + { + entropy: "77c2b00716cec7213839159e404db50d", + mnemonic: "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge", + seed: "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff", + }, + { + entropy: "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b", + mnemonic: "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap", + seed: "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5", + }, + { + entropy: "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982", + mnemonic: "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic", + seed: "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67", + }, + { + entropy: "0460ef47585604c5660618db2e6a7e7f", + mnemonic: "afford alter spike radar gate glance object seek swamp infant panel yellow", + seed: "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4", + }, + { + entropy: "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f", + mnemonic: "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left", + seed: "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba", + }, + { + entropy: "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416", + mnemonic: "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste", + seed: "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449", + }, + { + entropy: "eaebabb2383351fd31d703840b32e9e2", + mnemonic: "turtle front uncle idea crush write shrug there lottery flower risk shell", + seed: "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c", + }, + { + entropy: "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78", + mnemonic: "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment", + seed: "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79", + }, + { + entropy: "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef", + mnemonic: "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top", + seed: "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c", + }, + { + entropy: "18ab19a9f54a9274f03e5209a2ac8a91", + mnemonic: "board flee heavy tunnel powder denial science ski answer betray cargo cat", + seed: "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8", + }, + { + entropy: "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4", + mnemonic: "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief", + seed: "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9", + }, + { + entropy: "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419", + mnemonic: "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut", + seed: "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd", + }, + } +} + +//nolint:dupword,lll // the test vectors, kept as upstream wrote them +func badMnemonicSentences() []vector { + return []vector{ + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow yellow"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice caged above"}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo, wrong"}, + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will will will"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always."}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo why"}, + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art art"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thanks year wave worth useful legal winner thank year wave sausage worth title"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letters advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo voted"}, + {mnemonic: "jello better achieve collect unaware mountain thought cargo oxygen act hood bridge"}, + {mnemonic: "renew, stay, biology, evidence, goat, welcome, casual, join, adapt, armor, shuffle, fault, little, machine, walk, stumble, urge, swap"}, + {mnemonic: "dignity pass list indicate nasty"}, + + // From issue 32 + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon letter"}, + } +} + +func assertNil(t *testing.T, object any) { + t.Helper() + + if object != nil { + t.Errorf("Expected nil, got %v", object) + } +} + +func assertNotNil(t *testing.T, object any) { + t.Helper() + + if object == nil { + t.Error("Expected not nil") + } +} + +func assertTrue(t *testing.T, a bool) { + t.Helper() + + if !a { + t.Error("Expected true, got false") + } +} + +func assertFalse(t *testing.T, a bool) { + t.Helper() + + if a { + t.Error("Expected false, got true") + } +} + +func assertEqual(t *testing.T, a, b any) { + t.Helper() + + if a != b { + t.Errorf("Objects not equal, expected `%s` and got `%s`", a, b) + } +} + +func assertEqualString(t *testing.T, a, b string) { + t.Helper() + + if a != b { + t.Errorf("Strings not equal, expected `%s` and got `%s`", a, b) + } +} + +func assertEqualByteSlices(t *testing.T, a, b []byte) { + t.Helper() + + if len(a) != len(b) { + t.Errorf("Byte slices not equal, expected %v and got %v", a, b) + + return + } + + for i := range a { + if a[i] != b[i] { + t.Errorf("Byte slices not equal, expected %v and got %v", a, b) + + return + } + } +} diff --git a/internal/bip39/english.go b/internal/bip39/english.go new file mode 100644 index 0000000..ea2d3b1 --- /dev/null +++ b/internal/bip39/english.go @@ -0,0 +1,2061 @@ +package bip39 + +import ( + "strings" +) + +// English returns a slice of mnemonic words taken from the bip39 specification +// https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt +func English() []string { + return strings.Split(strings.TrimSpace(english), "\n") +} + +const english = `abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo +` diff --git a/internal/bip39/english_internal_test.go b/internal/bip39/english_internal_test.go new file mode 100644 index 0000000..c2652bd --- /dev/null +++ b/internal/bip39/english_internal_test.go @@ -0,0 +1,19 @@ +package bip39 + +import ( + "hash/crc32" + "testing" +) + +func TestEnglishChecksum(t *testing.T) { + t.Parallel() + + // Ensure word list is correct + // $ wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt + // $ crc32 english.txt + // c1dbd296 + checksum := crc32.ChecksumIEEE([]byte(english)) + if checksum != 0xc1dbd296 { + t.Error("english checksum invalid") + } +} diff --git a/internal/bip39/example_test.go b/internal/bip39/example_test.go new file mode 100644 index 0000000..5048cf9 --- /dev/null +++ b/internal/bip39/example_test.go @@ -0,0 +1,30 @@ +package bip39_test + +import ( + "encoding/hex" + "fmt" + + "sneak.berlin/go/keyfunc/internal/bip39" +) + +//nolint:lll // the test vector and its output, kept as upstream wrote them +func ExampleNewMnemonic() { + // the entropy can be any byte slice, generated how pleased, + // as long its bit size is a multiple of 32 and is within + // the inclusive range of {128,256} + entropy, _ := hex.DecodeString("066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad") + + // generate a mnemomic + mnemomic, _ := bip39.NewMnemonic(entropy) + fmt.Println(mnemomic) + // output: + // all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform +} + +//nolint:lll // the test vector and its output, kept as upstream wrote them +func ExampleNewSeed() { + seed := bip39.NewSeed("all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", "TREZOR") + fmt.Println(hex.EncodeToString(seed)) + // output: + // 26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d +} diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go index f795f1a..c4d6b89 100644 --- a/internal/childmnemonic/childmnemonic.go +++ b/internal/childmnemonic/childmnemonic.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" - "git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" + "sneak.berlin/go/keyfunc/internal/derive" ) // english is the number BIP-85 gives the English word list. diff --git a/internal/childmnemonic/childmnemonic_test.go b/internal/childmnemonic/childmnemonic_test.go index ccd8990..7c216d6 100644 --- a/internal/childmnemonic/childmnemonic_test.go +++ b/internal/childmnemonic/childmnemonic_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/stretchr/testify/require" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/derive" ) // The lengths the tool offers, and one it does not. diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index 4de2b60..a1f9f32 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -3,15 +3,26 @@ package age import ( + "context" + "errors" "fmt" "io" + "io/fs" "os" + "os/signal" "path/filepath" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/cli/signals" + "sneak.berlin/go/keyfunc/internal/derive" +) + +// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been +// received by the time the work writing the file --output names ends. +var ErrInterrupted = errors.New( + "interrupted by a signal; the output file was left as it was", ) // Command returns the age command and everything under it. @@ -128,8 +139,12 @@ func runDecrypt(cmd *cobra.Command, args []string) error { return through(cmd, args, key.Decrypt) } -// through opens the input and the output the arguments ask for, hands -// them to the work, and finishes the output afterwards either way. +// through opens the input the arguments ask for and hands it to the +// work, with the file --output names to write to, or the command's own +// output when it names none or names the same file as that output, and +// the command's own error output when it names the same file as that. +// Those two are the streams the tool already has, so whatever they are +// redirected to is written as the redirect says, never replaced. func through( cmd *cobra.Command, args []string, work func(io.Writer, io.Reader) error, @@ -141,14 +156,41 @@ func through( defer closeSrc() - dst, done, err := output(cmd) + name, err := cmd.Flags().GetString("output") if err != nil { - return err + return fmt.Errorf("reading the output file: %w", err) } - err = work(dst, src) + switch { + case name == "", same(name, cmd.OutOrStdout()): + return work(cmd.OutOrStdout(), src) + case same(name, cmd.ErrOrStderr()): + return work(cmd.ErrOrStderr(), src) + default: + return output(name, src, work) + } +} - return done(err) +// same reports whether the named path, followed to the end, is the +// file the stream writes to, whatever name it is reached by, such as +// /dev/stdout or /dev/fd/1 for standard output. +func same(name string, stream io.Writer) bool { + file, ok := stream.(*os.File) + if !ok { + return false + } + + streamInfo, err := file.Stat() + if err != nil { + return false + } + + info, err := os.Stat(name) + if err != nil { + return false + } + + return os.SameFile(info, streamInfo) } // input returns what to read from: the named file, or the command's @@ -167,35 +209,127 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { return file, func() { _ = file.Close() }, nil } -// output returns what to write to: a new file beside the one --output -// names, or the command's own output when it names none. The second -// result finishes the write, and is given whatever the work returned: -// the new file takes the named file's place only when the work -// succeeded, so a file that is already there survives a run that -// failed. -func output(cmd *cobra.Command) (io.Writer, func(error) error, error) { - name, err := cmd.Flags().GetString("output") +// output has the work write to the named path, going by what is there +// without following a final symlink: +// +// - nothing, or a regular file: replace writes a new file beside it +// and renames that over it; +// - a symlink: the same for what it points at, so that the link keeps +// pointing where it did; one that points at nothing is refused; +// - anything else, such as a named pipe or a device like /dev/null: +// direct writes to it, since a rename would put a regular file in +// its place. +func output( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + info, err := os.Lstat(name) + + switch { + case errors.Is(err, fs.ErrNotExist): + return replace(name, src, work) + case err != nil: + return fmt.Errorf("looking at %s: %w", name, err) + case info.Mode().IsRegular(): + return replace(name, src, work) + case info.Mode().Type() == fs.ModeSymlink: + // os.Stat follows the link as opening it would. /dev/fd/3 + // needs that: it reaches a pipe or a terminal through a link + // that names no path. + info, err = os.Stat(name) + if err != nil { + return fmt.Errorf("following %s: %w", name, err) + } + + if !info.Mode().IsRegular() { + return direct(name, src, work) + } + + target, err := filepath.EvalSymlinks(name) + if err != nil { + return fmt.Errorf("following %s: %w", name, err) + } + + return replace(target, src, work) + default: + return direct(name, src, work) + } +} + +// direct has the work write straight to the named path, which is there +// and is not a regular file. No signal is caught, so one ends the tool +// as it ends any other command. +func direct( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + file, err := os.OpenFile(name, os.O_WRONLY, 0) //nolint:gosec // the -o path if err != nil { - return nil, nil, fmt.Errorf("reading the output file: %w", err) + return fmt.Errorf("opening %s: %w", name, err) } - if name == "" { - return cmd.OutOrStdout(), func(failed error) error { - return failed - }, nil + failed := work(file, src) + closeErr := file.Close() + + if failed != nil { + return failed } + if closeErr != nil { + return fmt.Errorf("finishing %s: %w", name, closeErr) + } + + return nil +} + +// replace has the work write a new file beside the named one, and puts +// the new file in the named file's place only when the work succeeded, +// so a file that is already there survives a run that failed. +// +// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context +// does. One the tool has received by the time the work ends wins: the +// new file is removed and ErrInterrupted returned, at once if the work +// is still running, without waiting for it, since it may be blocked +// reading its input. +func replace( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + // received is registered before the context, so it gets every + // signal the context gets. + received := make(chan os.Signal, 1) + signals.Notify(received) + + defer signal.Stop(received) + + // The context goes on catching the signals until the file is in + // place or removed, so that a later one cannot end the tool with + // the new file left beside the named one. + interrupted, stop := signals.Context(context.Background()) + defer stop() + // The file is made in the same directory so that putting it in // place is a rename and never a copy, and it is readable only by // its owner, which is the mode it keeps once renamed. file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".") if err != nil { - return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err) + return fmt.Errorf("creating a file beside %s: %w", name, err) } - return file, func(failed error) error { - return finish(file, name, failed) - }, nil + worked := make(chan error, 1) + + go func() { worked <- work(file, src) }() + + select { + case failed := <-worked: + // Stop returns only once every signal the tool has received + // has been handed over, so an empty received means none came. + signal.Stop(received) + + if len(received) == 0 { + return finish(file, name, failed) + } + case <-interrupted.Done(): + } + + return finish(file, name, ErrInterrupted) } // finish closes the new file and puts it in the named file's place, or diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 3d452b9..2ee31e7 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -1,14 +1,23 @@ package cli_test import ( + "errors" + "io" + "io/fs" "os" + "os/exec" + "os/signal" "path/filepath" "strings" + "syscall" "testing" + "time" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli/age" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) func TestTheAgeCommandsPrintTheKey(t *testing.T) { @@ -90,6 +99,360 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) { require.Equal(t, "what was already there\n", string(kept)) } +func TestASymlinkAtTheOutputPathStaysAndItsTargetGetsTheOutput(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + target := written(t, "notes.age", "what was already there\n") + link := filepath.Join(t.TempDir(), "notes.age") + require.NoError(t, os.Symlink(target, link)) + + run(t, "age", "encrypt", "-o", link, plain) + + pointsAt, err := os.Readlink(link) + require.NoError(t, err) + require.Equal(t, target, pointsAt) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", target)) +} + +func TestANamedPipeAtTheOutputPathIsWrittenToAndStaysAPipe(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + pipe := filepath.Join(t.TempDir(), "notes.age") + require.NoError(t, syscall.Mkfifo(pipe, fileMode)) + + // Opening the pipe to read waits until the tool opens it to write. + var sealed []byte + + finished := make(chan error, 1) + + go func() { + var err error + + sealed, err = os.ReadFile(pipe) //nolint:gosec // the test's own path + finished <- err + }() + + run(t, "age", "encrypt", "-o", pipe, plain) + + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("nothing was written to the pipe") + } + + info, err := os.Lstat(pipe) + require.NoError(t, err) + require.Equal(t, fs.ModeNamedPipe, info.Mode().Type()) + + sealedFile := written(t, "notes.age", string(sealed)) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealedFile)) +} + +func TestANameForStandardOutputAddsToTheFileItIsAppendedTo(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := filepath.Join(t.TempDir(), "notes.age") + run(t, "age", "encrypt", "-o", sealed, written(t, "notes.txt", "the secret\n")) + + for _, name := range []string{"/dev/stdout", "/dev/fd/1"} { + appendedThrough(t, name, sealed) + } +} + +// appendedThrough decrypts sealed with -o name while the tool's standard +// output is appended to a file that already has contents, as the shell's +// ">> notes.out" does, and checks that the file is the same one, with +// the same mode, and holds its earlier contents and then the output. +func appendedThrough(t *testing.T, name, sealed string) { + t.Helper() + + // A mode of its own, so that a replaced file would show. + const ownMode = 0o644 + + existing := written(t, "notes.out", "what was already there\n") + require.NoError(t, os.Chmod(existing, ownMode)) + + before, err := os.Stat(existing) + require.NoError(t, err) + + //nolint:gosec // the test made this path itself + appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0) + require.NoError(t, err) + + defer func() { _ = appended.Close() }() + + //nolint:gosec // this test's own binary as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], "age", "decrypt", "-o", name, sealed, + ) + + command.Env = append(os.Environ(), runAsTool+"=1") + command.Stdout = appended + + require.NoError(t, command.Run(), name) + require.Equal(t, + "what was already there\nthe secret\n", read(t, existing), name, + ) + + after, err := os.Stat(existing) + require.NoError(t, err) + require.True(t, os.SameFile(before, after), name) + require.Equal(t, os.FileMode(ownMode), after.Mode().Perm(), name) +} + +func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + interrupted(t, ending, "encrypt", "the start of the secret\n") + } +} + +func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + // All of an encryption but its last byte, so the tool reads the + // header and then waits for the rest. + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + cut := sealed[:len(sealed)-1] + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + interrupted(t, ending, "decrypt", cut) + } +} + +func TestASignalReceivedAsTheInputEndsLeavesTheFileAsItWas(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + + for _, ending := range []syscall.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + receivedAtTheEnd(t, ending, "encrypt", "the secret\n") + receivedAtTheEnd(t, ending, "decrypt", sealed) + } +} + +func TestASignalAsTheInputEndsLeavesNoUnfinishedFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + + // Ctrl-C on "producer | keyfunc age encrypt -o file" ends the + // producer too, so the input ends just as the signal comes, with + // enough of it in hand for a whole encryption or decryption. Which + // of the two the tool has first varies, so it is tried often, and + // a whole file in place is accepted as well as none. + for range 25 { + named := signalledAsTheInputEnds(t, "encrypt", "the start of the secret\n") + if named != "" { + require.Equal(t, + "the start of the secret\n", run(t, "age", "decrypt", named), + ) + } + + named = signalledAsTheInputEnds(t, "decrypt", sealed) + if named != "" { + require.Equal(t, "the secret\n", read(t, named)) + } + } +} + +func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + directory := t.TempDir() + named := filepath.Join(directory, "notes") + + // nohup starts the tool with SIGHUP ignored. A tool that caught it + // anyway would turn it back on and be ended by it. + command, producer := writing( + t, directory, "the secret\n", + "nohup", os.Args[0], "age", "encrypt", "-o", named, + ) + + require.NoError(t, command.Process.Signal(syscall.SIGHUP)) + require.NoError(t, producer.Close()) + waitForTool(t, "SIGHUP under nohup", command) + + require.Equal(t, 0, command.ProcessState.ExitCode()) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Len(t, left, 1) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", named)) +} + +// interrupted runs "age encrypt -o" or "age decrypt -o", as the +// operation says, writing into a directory of its own, and once it has +// begun writing sends it the signal and leaves the input open. The tool +// has to end with status 1 and leave the directory empty. A tool that +// went on reading would not end until the input did; one that did not +// remove the file it was writing would leave it there, with what it had +// written so far. +func interrupted(t *testing.T, ending os.Signal, operation, input string) { + t.Helper() + + name := operation + " " + ending.String() + directory := t.TempDir() + + command, _ := writing( + t, directory, input, + os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"), + ) + + require.NoError(t, command.Process.Signal(ending)) + waitForTool(t, name, command) + + require.Equal(t, 1, command.ProcessState.ExitCode(), name) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Empty(t, left, name) +} + +// signalledAsTheInputEnds runs "age encrypt -o" or "age decrypt -o", as +// the operation says, writing into a directory of its own, and once it +// has begun writing sends it SIGINT and at once ends its input. Either +// the tool ends with status 1 and leaves the directory empty, and "" +// is returned, or it ends otherwise and leaves only the named file, +// whose path is returned for the caller to check that it is whole. +func signalledAsTheInputEnds(t *testing.T, operation, input string) string { + t.Helper() + + directory := t.TempDir() + named := filepath.Join(directory, "notes") + + command, producer := writing( + t, directory, input, os.Args[0], "age", operation, "-o", named, + ) + + require.NoError(t, command.Process.Signal(syscall.SIGINT)) + require.NoError(t, producer.Close()) + waitForTool(t, operation, command) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + + if command.ProcessState.ExitCode() == failedStatus { + require.Empty(t, left, operation) + + return "" + } + + require.Len(t, left, 1, operation) + + return named +} + +// receivedAtTheEnd runs "age encrypt -o" or "age decrypt -o", as the +// operation says, in this process, over a file that is already there, +// with an input that at its end sends this process the signal and waits +// until it has been received. The tool has to return ErrInterrupted and +// leave that file as it was, with nothing beside it. A tool that went +// by the end of the input alone would put its new file in place. +func receivedAtTheEnd( + t *testing.T, ending syscall.Signal, operation, input string, +) { + t.Helper() + + name := operation + " " + ending.String() + existing := written(t, "notes", "what was already there\n") + + // The test catches the signal as well, so that it does not end the + // test binary and so that the input can wait for it. + received := make(chan os.Signal, 1) + signal.Notify(received, ending) + + defer signal.Stop(received) + + root := cli.Root() + root.SetIn(&endingInASignal{ + rest: strings.NewReader(input), ending: ending, received: received, + }) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"age", operation, "-o", existing}) + + err := root.ExecuteContext(t.Context()) + require.ErrorIs(t, err, age.ErrInterrupted, name) + + require.Equal(t, "what was already there\n", read(t, existing), name) + + left, err := os.ReadDir(filepath.Dir(existing)) + require.NoError(t, err) + require.Len(t, left, 1, name) +} + +// endingInASignal is an input that, when it runs out, sends this +// process its signal and waits for it on received before it reports its +// end. It sends the signal only once: once nothing catches it, another +// would end the test binary. +type endingInASignal struct { + rest io.Reader + ending syscall.Signal + received chan os.Signal + sent bool +} + +func (input *endingInASignal) Read(buffer []byte) (int, error) { + n, err := input.rest.Read(buffer) + if !errors.Is(err, io.EOF) || input.sent { + return n, err + } + + input.sent = true + + err = syscall.Kill(os.Getpid(), input.ending) + if err != nil { + return n, err + } + + <-input.received + + return n, io.EOF +} + +// writing starts argv, the tool told to write into directory, as a +// subprocess reading the input from a pipe, and returns once the tool +// has begun writing the file beside the one it was named. The pipe is +// left open for the caller to end. +func writing( + t *testing.T, directory, input string, argv ...string, +) (*exec.Cmd, io.WriteCloser) { + t.Helper() + + //nolint:gosec // this test's own binary as the tool, or nohup running it + command := exec.CommandContext(t.Context(), argv[0], argv[1:]...) + + command.Env = append(os.Environ(), runAsTool+"=1") + + producer, err := command.StdinPipe() + require.NoError(t, err) + require.NoError(t, command.Start()) + + _, err = io.WriteString(producer, input) + require.NoError(t, err) + + // The file beside the named one is made once the mnemonic has been + // read, before any input is. + require.Eventually(t, func() bool { + entries, err := os.ReadDir(directory) + + return err == nil && len(entries) > 0 + }, 5*time.Second, 5*time.Millisecond) + + return command, producer +} + // written puts the contents in a file of that name in a directory of // this test's own and returns the path to it. func written(t *testing.T, name, contents string) string { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6d63d0..dabad05 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,28 +5,53 @@ import ( "errors" "fmt" "os" + "runtime" + "runtime/debug" - "git.eeqj.de/sneak/keyfunc/internal/cli/age" - "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/age" + "sneak.berlin/go/keyfunc/internal/cli/mnemonic" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/cli/ssh" ) -// Version is what --version prints. The build sets it. +// devVersion is what Version holds until a build stamps a real one. +const devVersion = "dev" + +// Version is what --version prints. make build stamps it with -ldflags. // //nolint:gochecknoglobals // set at build time with -ldflags -var Version = "dev" +var Version = devVersion + +// resolveVersion chooses what --version reports. A value stamped at +// build time wins. Otherwise, for a binary from go install, the module +// version recorded in the build info is used, unless that is empty or +// the "(devel)" of a local build. When neither names a version, the +// "dev" fallback stays. +func resolveVersion(stamped string, info *debug.BuildInfo) string { + if stamped != devVersion { + return stamped + } + + if info != nil && info.Main.Version != "" && + info.Main.Version != "(devel)" { + return info.Main.Version + } + + return devVersion +} // Root returns the whole command tree. func Root() *cobra.Command { + info, _ := debug.ReadBuildInfo() + root := &cobra.Command{ Use: "keyfunc", Short: "derive key pairs from a BIP-39 mnemonic", Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " + "be recreated from that mnemonic at any time. The same " + "mnemonic, key type and index always give the same key.", - Version: Version, + Version: resolveVersion(Version, info), SilenceUsage: true, SilenceErrors: true, } @@ -37,19 +62,38 @@ func Root() *cobra.Command { return root } +// init keeps the command on the main thread. Linux hands a signal sent +// to the tool to that thread first, and a thread runs a pending signal +// handler before its own code, so when "age encrypt -o" or "age +// decrypt -o" checks for a signal as its input ends, one sent before +// then, as by Ctrl-C on a pipeline, has been received. +// +//nolint:gochecknoinits // only an init can keep main on the main thread +func init() { + runtime.LockOSThread() +} + // Main runs the tool and returns the status the process should exit // with. An error ends the tool with status 1, except when it carries a // status of its own, which "ssh to" uses to hand on the status ssh // ended with. ssh has already said whatever it had to say in that // case, so nothing more is printed. +// +// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go +// program, so a command waiting at the mnemonic prompt or reading what +// it encrypts or decrypts goes no further. The exceptions catch the +// signals to clean up first: "ssh to" and "ssh install" while they +// have ssh or sftp running, so the child ends and their own cleanup +// still runs, and "age encrypt -o" and "age decrypt -o" while they +// write a new file to rename over the named one, so the unfinished file +// is removed. func Main() int { err := Root().Execute() if err == nil { return 0 } - var passed ssh.StatusError - if errors.As(err, &passed) { + if passed, ok := errors.AsType[ssh.StatusError](err); ok { return passed.Status } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index fa11a40..0b29adc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,13 +5,13 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli" - "git.eeqj.de/sneak/keyfunc/internal/derive" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" - bip39 "github.com/tyler-smith/go-bip39" "golang.org/x/crypto/ssh" + "sneak.berlin/go/keyfunc/internal/bip39" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // The two lines the README says the example mnemonic produces. @@ -22,6 +22,11 @@ const ( "0I4FKs+eVUulTPHfk9VtXw1tMF" ) +// The child mnemonic the README says the example mnemonic gives at +// index 0. +const childZero = "prosper short ramp prepare exchange stove life " + + "snack client enough purpose fold" + // The two child mnemonic lengths the tests ask for. const ( twelve = 12 @@ -45,6 +50,28 @@ func TestTheReadmeTestVectors(t *testing.T) { vectorOne+" keyfunc/ssh/1", strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")), ) + require.Equal(t, + childZero, strings.TrimSpace(run(t, "mnemonic", "-n", "0")), + ) +} + +func TestTheSpacingBetweenTheWordsDoesNotChangeTheKeys(t *testing.T) { + words := strings.Fields(example()) + + for name, spaced := range map[string]string{ + "one word per line": strings.Join(words, "\n"), + "double spaces": strings.Join(words, " "), + "tabs": strings.Join(words, "\t"), + } { + t.Run(name, func(t *testing.T) { + t.Setenv(mnemonic.Variable, spaced) + + require.Equal(t, + vectorZero+" keyfunc/ssh/0", + strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")), + ) + }) + } } func TestTheCommentCanBeChosen(t *testing.T) { diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go index e157e18..8c33332 100644 --- a/internal/cli/mnemonic/mnemonic.go +++ b/internal/cli/mnemonic/mnemonic.go @@ -4,10 +4,10 @@ package mnemonic import ( "fmt" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/derive" ) // Command returns the mnemonic command. diff --git a/internal/cli/options/options.go b/internal/cli/options/options.go index cfad875..5d454ec 100644 --- a/internal/cli/options/options.go +++ b/internal/cli/options/options.go @@ -5,8 +5,8 @@ package options import ( "fmt" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // Add gives a command the flags that every command has. They are diff --git a/internal/cli/signals/signals.go b/internal/cli/signals/signals.go new file mode 100644 index 0000000..1657564 --- /dev/null +++ b/internal/cli/signals/signals.go @@ -0,0 +1,53 @@ +// Package signals catches the signals that end the tool, for the +// commands that clean up before they end. +package signals + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the +// context it returns is cancelled when one of them arrives, and stop +// stops catching them. It leaves out any of the three the tool was +// started with set to be ignored, as nohup does with SIGHUP, because +// catching a signal turns an ignored one back on and would end a run +// that was meant to survive it. +func Context(parent context.Context) (context.Context, context.CancelFunc) { + endings := caught() + + // Given no signals at all, NotifyContext would catch every one. + if len(endings) == 0 { + return context.WithCancel(parent) + } + + return signal.NotifyContext(parent, endings...) +} + +// Notify is signal.Notify for the signals Context catches: each one +// that arrives is sent to c, until signal.Stop(c). +func Notify(c chan<- os.Signal) { + endings := caught() + + // Given no signals at all, Notify would catch every one. + if len(endings) > 0 { + signal.Notify(c, endings...) + } +} + +// caught returns those of SIGINT, SIGTERM and SIGHUP that the tool was +// not started with set to be ignored. +func caught() []os.Signal { + endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP} + kept := make([]os.Signal, 0, len(endings)) + + for _, ending := range endings { + if !signal.Ignored(ending) { + kept = append(kept, ending) + } + } + + return kept +} diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 8133261..1837e68 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -1,58 +1,95 @@ package ssh import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" "fmt" + "os" "os/exec" + "path/filepath" "slices" "strings" + "syscall" + "time" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) -// script is what runs on the host. It reads the key line from its own -// standard input, so the line never appears on a command line, where -// anyone else on the host could read it out of the process list. It -// contains no single quote, so the whole of it travels through ssh -// inside one pair of them. The umask keeps anything it makes to the -// owner from the start; the modes are then set outright, whatever the -// umask on the host turns out to be. A file whose last line has no -// newline at its end gets one before the key line goes on, so that the -// two do not run into each other. -const script = ` -set -e -umask 077 -directory="$HOME/.ssh" -file="$directory/authorized_keys" -if [ ! -d "$directory" ]; then - mkdir -p "$directory" - chmod 700 "$directory" -fi -if [ ! -f "$file" ]; then - : > "$file" - chmod 600 "$file" -fi -IFS= read -r line -if grep -q -x -F -e "$line" "$file"; then - echo "already present" -else - if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then - printf "\n" >> "$file" - fi - printf "%s\n" "$line" >> "$file" - echo "added" -fi -` +// Where the key goes on the host and what the file it arrives in is +// called before it is renamed into place. The random end of that name +// keeps two runs at once from writing to the same file. +const ( + directory = ".ssh" + authorized = ".ssh/authorized_keys" + sidecarPrefix = ".ssh/authorized_keys.keyfunc-" + sidecarBytes = 8 +) + +// The modes the host is left with, as sftp's chmod spells them, and +// the mode of the copy made here on the way. +const ( + directoryMode = "700" + fileMode = "600" + localMode = 0o600 +) + +// waitDelay is the WaitDelay sftp runs with: from a signal, or from sftp +// ending, how long the tool waits for sftp to end and its output to +// close before it kills sftp and stops reading. That is ample for sftp +// to stop the ssh it started, and short enough that a signal still ends +// the tool within a second. +const waitDelay = 250 * time.Millisecond + +// ErrCannotEnter is the refusal of a host whose .ssh is there but +// cannot be entered, so that nothing in it can be read or written. +var ErrCannotEnter = errors.New( + "~/.ssh is there on the host but cannot be entered", +) + +// ErrSymlink is the refusal of a host whose authorized_keys is a +// symlink: the rename that puts the new file in place would replace the +// link itself, and the file it points at would never get the key. +var ErrSymlink = errors.New( + "~/.ssh/authorized_keys on the host is a symlink, which the tool " + + "leaves alone", +) + +// ErrStrayArgument is the refusal of anything but the host before --, +// which would otherwise be handed to sftp in front of the host. +var ErrStrayArgument = errors.New( + "only the host goes before --; options for sftp go after --", +) // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ - Use: "install <[user@]host> [-- ssh options...]", + Use: "install <[user@]host> [-- sftp options...]", Short: "add the public key to a host's authorized_keys", - Long: "Runs the system ssh to the host, which makes ~/.ssh and " + - "~/.ssh/authorized_keys there if they are missing and adds " + - "the public key unless the same line is already in the " + - "file. Anything after -- is given to ssh unchanged.", - Args: cobra.MinimumNArgs(1), + Long: "Downloads the host's authorized_keys with the system " + + "sftp, adds the public key to it here unless the same " + + "line is already there, and uploads the result as a file " + + "beside it which is then renamed over it. Nothing is run " + + "on the host. Anything after -- is given to sftp " + + "unchanged, which is where the port goes (-P).", + Args: cobra.MatchAll( + cobra.MinimumNArgs(1), + func(cmd *cobra.Command, args []string) error { + // ArgsLenAtDash is -1 when there is no --. + before := cmd.ArgsLenAtDash() + if before == -1 { + before = len(args) + } + + if before != 1 { + return ErrStrayArgument + } + + return nil + }, + ), RunE: func(cmd *cobra.Command, args []string) error { key, comment, err := derived(cmd) if err != nil { @@ -64,7 +101,15 @@ func install() *cobra.Command { return err } - return send(cmd, args[0], args[1:], line) + // From here on a signal cancels the context, which + // sftp runs under, instead of ending the tool, so sftp + // ends and the working directory is still removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + cmd.SetContext(ctx) + + return add(cmd, args[0], args[1:], line) }, } @@ -73,24 +118,321 @@ func install() *cobra.Command { return cmd } -// send runs ssh to the host with the user's options, gives it the -// script to run there, and writes the key line to its standard input. -// What the host says, added or already present, is passed straight on. -func send(cmd *cobra.Command, host string, options []string, line string) error { - argv := slices.Concat(options, []string{ - host, "/bin/sh -c '" + script + "'", - }) - - //nolint:gosec // the options are the user's own, meant for ssh - command := exec.CommandContext(cmd.Context(), "ssh", argv...) - command.Stdin = strings.NewReader(line + "\n") - command.Stdout = cmd.OutOrStdout() - command.Stderr = cmd.ErrOrStderr() - - err := command.Run() +// add puts the key line in the host's authorized_keys. The file is +// fetched in one sftp session and written back in another, so a run +// that adds a line connects twice; a run that finds the line already +// there connects once and stops. +func add(cmd *cobra.Command, host string, options []string, line string) error { + work, err := os.MkdirTemp("", "keyfunc-install-") if err != nil { - return fmt.Errorf("running ssh: %w", err) + return fmt.Errorf("making a temporary directory: %w", err) } - return nil + defer func() { _ = os.RemoveAll(work) }() + + content, present, err := fetch(cmd, host, options, + filepath.Join(work, "authorized_keys"), + ) + if err != nil { + return err + } + + merged, added := merge(content, line) + if !added { + return write(cmd, "already present\n") + } + + return upload(cmd, host, options, work, merged, present) +} + +// upload writes the new file to the host and renames it over +// authorized_keys, which is the step that either happens or does not. +// Nothing is removed when a step fails: the file left behind is named +// so that it can be looked at and cleared away by hand. The directory +// is made and set to its mode only when the read found none: an .ssh +// that was already there is left with the mode it had. +func upload( + cmd *cobra.Command, host string, options []string, + work, merged string, present bool, +) error { + local := filepath.Join(work, "authorized_keys.merged") + + err := os.WriteFile(local, []byte(merged), localMode) + if err != nil { + return fmt.Errorf("writing the new file: %w", err) + } + + sidecar, err := sidecarName() + if err != nil { + return err + } + + var batch []string + + if !present { + // The mkdir is allowed to fail in case the directory appeared + // between the read and now; the chmod then sets its mode. + batch = append(batch, + "-mkdir "+directory, + "chmod "+directoryMode+" "+directory, + ) + } + + batch = append(batch, + "put "+quoted(local)+" "+sidecar, + "chmod "+fileMode+" "+sidecar, + "rename "+sidecar+" "+authorized, + ) + + said, err := session(cmd, host, options, batch) + if err != nil { + // sftp echoes each command as it runs it and stops at the + // first that fails, so the name is in what it said only once + // the put was reached, which is where a file of that name + // can be on the host. Before that there is none to name. + if strings.Contains(said, sidecar) { + return fmt.Errorf( + "%w; %s may be left on the host", err, sidecar, + ) + } + + return err + } + + return write(cmd, "added\n") +} + +// session runs one sftp session with the user's own options and the +// batch of commands, which sftp reads from its standard input and +// stops at the first of which that fails, unless it begins with a +// dash. sftp echoes the commands as it runs them, so everything it +// says goes to the error output and the tool's own output stays the +// one word it prints. What it said is also given back: a session that +// failed says there what went wrong, and the status alone does not. +func session( + cmd *cobra.Command, host string, options []string, batch []string, +) (string, error) { + argv := slices.Concat( + []string{"-b", "-"}, options, []string{host}, + ) + + var said bytes.Buffer + + //nolint:gosec // the options are the user's own, meant for sftp + command := exec.CommandContext(cmd.Context(), "sftp", argv...) + command.Env = childEnv() + command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") + command.Stdout = &said + command.Stderr = &said + + // A cancelled context means a signal arrived. Send sftp a SIGTERM + // rather than the default kill, so it stops the ssh it started + // before it goes. Anything sftp started that still holds its output + // keeps the tool waiting no longer than waitDelay. + command.Cancel = func() error { + return command.Process.Signal(syscall.SIGTERM) + } + command.WaitDelay = waitDelay + + err := command.Run() + + // sftp ended well and only something it started, such as the + // master ssh leaves running for ControlPersist under -v, still held + // its output: the session worked. + if errors.Is(err, exec.ErrWaitDelay) { + err = nil + } + + _, _ = cmd.ErrOrStderr().Write(said.Bytes()) + + if err != nil { + return said.String(), fmt.Errorf("running sftp: %w", err) + } + + return said.String(), nil +} + +// merge returns the file with the key line on the end, and whether it +// had to be added. A file whose last line has no newline at its end +// gets one first, so that the two lines do not run into each other. +func merge(content, line string) (string, bool) { + if slices.Contains(strings.Split(content, "\n"), line) { + return content, false + } + + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + return content + line + "\n", true +} + +// fetch brings the host's authorized_keys into the given path and +// returns what is in it, and whether the .ssh directory was already +// there. The one session lists .ssh, then .ssh/., and then gets the +// file, so the listings settle the state of the directory before the +// get is read. +// +// The file reads as empty in just two cases: sftp reported .ssh itself +// as not there, or both listings succeeded and the get then reported +// the file as not there. Anything else — a listing refused, the file +// there but unreadable, the connection down — fails the run and writes +// nothing, because writing back over what was not read would leave the +// host with the new key and nothing else. sftp cannot tell a missing +// file from one in a directory it cannot enter, so the listings do: a +// directory that is there but cannot be read fails the first, and one +// that can be read but not entered fails the second, because nothing in +// it can be looked up, not even ".". The first listing of such a +// directory comes up empty, as the server leaves out every name it +// cannot look up. +// +// The first listing is a long one, which shows an authorized_keys that +// is a symlink as one. That is refused before anything else sftp said +// is read, so a link the get could not follow is refused in the same +// words. +func fetch( + cmd *cobra.Command, host string, options []string, into string, +) (string, bool, error) { + said, err := session(cmd, host, options, []string{ + "ls -n " + directory, + "ls -1 " + directory + "/.", + "get " + authorized + " " + quoted(into), + }) + if symlinked(said) { + return "", false, ErrSymlink + } + + if err != nil { + if listingNotFound(said, directory) { + return "", false, nil + } + + if listingNotFound(said, directory+"/.") { + return "", false, ErrCannotEnter + } + + if absent(said) { + return "", true, nil + } + + return "", false, err + } + + //nolint:gosec // the path is a temporary file of the tool's own + content, err := os.ReadFile(into) + if err != nil { + return "", false, fmt.Errorf("reading the fetched file: %w", err) + } + + return string(content), true, nil +} + +// listingNotFound says whether sftp reported the path it was asked to +// list as not being there. For .ssh that is the one listing failure +// read as a host that has no authorized_keys yet; for .ssh/., once .ssh +// itself has been listed, it is a .ssh that is there but cannot be +// entered. The reading is taken only from the line in which sftp +// reports on that path: any other failure of a listing, in particular a +// directory that is there but cannot be read, is left as a failure, so +// that no key is written to a host whose keys were never read. +func listingNotFound(said, path string) bool { + for line := range strings.Lines(said) { + named, is := reportedCannotList(strings.TrimSpace(line)) + if is && (named == path || strings.HasSuffix(named, "/"+path)) { + return true + } + } + + return false +} + +// reportedCannotList returns the path an sftp line reports it cannot +// list for want of it, and whether the line is such a report. The +// client writes this one wording when it cannot look up the path a +// listing names, giving the path the server expanded. +func reportedCannotList(line string) (string, bool) { + const ( + before = `Can't ls: "` + after = `" not found` + ) + + if !strings.HasPrefix(line, before) || + !strings.HasSuffix(line, after) { + return "", false + } + + return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true +} + +// symlinked says whether the long listing of .ssh shows authorized_keys +// as a symlink. With -n the client writes each line itself, as ls -l +// does, whatever the server: the type comes first, "l" for a symlink, +// and the path as the listing named it comes last. +func symlinked(said string) bool { + for line := range strings.Lines(said) { + fields := strings.Fields(line) + if len(fields) > 0 && strings.HasPrefix(fields[0], "l") && + fields[len(fields)-1] == authorized { + return true + } + } + + return false +} + +// absent says whether sftp reported the file that was asked for as +// not being there, which is the one failure of the fetch that is read +// as an empty authorized_keys. The reading is taken only from the +// line in which sftp reports on that file, because ssh writes "no +// such file" into the same output for reasons of its own — a missing +// -i identity file draws that warning on a session that then +// authenticates through the agent — and a real read failure on such a +// session must not pass for an empty file. +func absent(said string) bool { + for line := range strings.Lines(said) { + named, is := reportedNotFound(strings.TrimSpace(line)) + if is && (named == authorized || + strings.HasSuffix(named, "/"+authorized)) { + return true + } + } + + return false +} + +// reportedNotFound returns the path an sftp line reports as not being +// there, and whether the line is such a report. The client writes one +// wording for a remote file it cannot find, naming the path the +// server expanded, which is the absolute one. +func reportedNotFound(line string) (string, bool) { + const ( + before = `File "` + after = `" not found.` + ) + + if !strings.HasPrefix(line, before) || + !strings.HasSuffix(line, after) { + return "", false + } + + return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true +} + +// sidecarName returns the name the new file is uploaded under. +func sidecarName() (string, error) { + random := make([]byte, sidecarBytes) + + _, err := rand.Read(random) + if err != nil { + return "", fmt.Errorf("making a name for the new file: %w", err) + } + + return sidecarPrefix + hex.EncodeToString(random), nil +} + +// quoted puts the double quotes around a path that sftp needs when the +// path has a space in it. Only paths of the tool's own making are +// given to it, and they hold no quote of their own. +func quoted(path string) string { + return `"` + path + `"` } diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go new file mode 100644 index 0000000..cc25219 --- /dev/null +++ b/internal/cli/ssh/install_test.go @@ -0,0 +1,141 @@ +//nolint:testpackage // absent is what these wordings are read by +package ssh + +import "testing" + +// What a session says besides its report on the file that was asked +// for: sftp echoes the command it is running, and ssh warns about an +// identity file it cannot find in the words of a missing file even +// though the session goes on to authenticate. +const ( + echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" +` + listed = "sftp> ls -n .ssh\n" + warning = `Warning: Identity file /gone not accessible: ` + + "No such file or directory.\n" +) + +// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the +// wordings the OpenSSH client was seen to use against a real server: +// a file it cannot find is reported one way, naming the path the +// server expanded, and everything else it says is a failure. +func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) { + t.Parallel() + + sessions := map[string]struct { + said string + want bool + }{ + "the file is not there": { + said: echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there, named as it was asked for": { + said: echoed + `File ".ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there and an identity file is not either": { + said: warning + echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is there and cannot be read": { + said: echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "only an identity file is not there": { + said: warning + echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "some other file is not there": { + said: echoed + `File "/home/someone/.ssh/known_hosts" not found.` + + "\n", + want: false, + }, + "the connection did not come up": { + said: "ssh: connect to host example.com port 22: " + + "Connection refused\nConnection closed\n", + want: false, + }, + } + + for name, session := range sessions { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if absent(session.said) != session.want { + t.Errorf( + "read as absent: %t, wanted %t, from:\n%s", + !session.want, session.want, session.said, + ) + } + }) + } +} + +// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the +// wordings the OpenSSH client was seen to use when a listing fails: a +// directory it cannot find is reported one way, and one it cannot read +// another, and only the first is read as a host with no .ssh yet. A +// .ssh that can be read but not entered lists as empty, and the +// listing of .ssh/. that follows reports that path, not .ssh, as not +// found. +func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) { + t.Parallel() + + listings := map[string]struct { + said string + want bool + }{ + "the directory is not there": { + said: listed + `Can't ls: "/home/someone/.ssh" not found` + "\n", + want: true, + }, + "the directory is not there, named as it was asked for": { + said: listed + `Can't ls: ".ssh" not found` + "\n", + want: true, + }, + "the directory is not there and an identity file is not either": { + said: warning + listed + + `Can't ls: "/home/someone/.ssh" not found` + "\n", + want: true, + }, + "the directory is there and cannot be read": { + said: listed + + `remote readdir("/home/someone/.ssh/"): Permission denied` + "\n", + want: false, + }, + "the directory is there and cannot be entered": { + said: listed + "sftp> ls -1 .ssh/.\n" + + `Can't ls: "/home/someone/.ssh/." not found` + "\n", + want: false, + }, + "some other directory is not there": { + said: listed + `Can't ls: "/home/someone/.config" not found` + "\n", + want: false, + }, + "the connection did not come up": { + said: "ssh: connect to host example.com port 22: " + + "Connection refused\nConnection closed\n", + want: false, + }, + } + + for name, listing := range listings { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if listingNotFound(listing.said, directory) != listing.want { + t.Errorf( + "read as absent: %t, wanted %t, from:\n%s", + !listing.want, listing.want, listing.said, + ) + } + }) + } +} diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index b15a08b..9fa73f6 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -3,11 +3,14 @@ package ssh import ( "fmt" + "os" + "strings" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/derive" - "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/mnemonic" + "sneak.berlin/go/keyfunc/internal/sshkey" ) // Command returns the ssh command and everything under it. @@ -84,6 +87,26 @@ func write(cmd *cobra.Command, text string) error { return nil } +// childEnv is the tool's environment with the mnemonic variables taken +// out, for the ssh and sftp children it starts. "ssh to" exists so the +// private key never leaves the tool; the mnemonic, from either variable, +// must not leave it either. +func childEnv() []string { + environ := os.Environ() + kept := make([]string, 0, len(environ)) + + for _, entry := range environ { + name, _, _ := strings.Cut(entry, "=") + if name == mnemonic.Variable || name == mnemonic.CommandVariable { + continue + } + + kept = append(kept, entry) + } + + return kept +} + // addComment gives a command its comment flag. func addComment(cmd *cobra.Command) { cmd.Flags().String( diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 8dc5149..c165796 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -7,8 +7,10 @@ import ( "os" "os/exec" "slices" + "syscall" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) // StatusError says the tool should end with the status ssh ended with. @@ -41,7 +43,14 @@ func to() *cobra.Command { return err } - served, err := key.Serve(cmd.Context(), comment) + // From here until the agent is taken down, a signal + // cancels the context instead of ending the tool, so + // ssh ends and the socket and its directory are still + // removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + served, err := key.Serve(ctx, comment) if err != nil { return err } @@ -52,7 +61,7 @@ func to() *cobra.Command { "-o", "IdentityAgent=" + served.Socket(), }, args) - return connect(cmd.Context(), argv) + return connect(ctx, argv) }, } @@ -70,17 +79,24 @@ func to() *cobra.Command { func connect(ctx context.Context, argv []string) error { //nolint:gosec // the arguments are the user's own, meant for ssh command := exec.CommandContext(ctx, "ssh", argv...) + command.Env = childEnv() command.Stdin = os.Stdin command.Stdout = os.Stdout command.Stderr = os.Stderr + // A cancelled context means a signal arrived. Send ssh a + // SIGTERM rather than the default kill, so it puts the terminal + // back the way it found it before it goes. + command.Cancel = func() error { + return command.Process.Signal(syscall.SIGTERM) + } + err := command.Run() if err == nil { return nil } - var ended *exec.ExitError - if errors.As(err, &ended) { + if ended, ok := errors.AsType[*exec.ExitError](err); ok { status := ended.ExitCode() if status < 0 { // A signal ended ssh, and a signal has no status of its diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 6b787fc..3aa8ef8 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -1,20 +1,42 @@ package cli_test import ( + "bytes" "os" + "os/exec" "path/filepath" + "slices" "strconv" "strings" + "syscall" "testing" + "time" - "git.eeqj.de/sneak/keyfunc/internal/cli" - "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli/ssh" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) +// runAsTool, set in the environment of a re-executed test binary, tells +// TestMain to run the tool through Main rather than the suite, so the +// signal tests can drive the real signal path in a process they can +// send a signal to. +const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL" + +// TestMain re-executes the test binary as the tool when runAsTool is +// set, and otherwise runs the suite. The signal tests start the tool +// this way, as a subprocess they can signal and watch end. +func TestMain(m *testing.M) { + if os.Getenv(runAsTool) == "1" { + os.Exit(cli.Main()) + } + + os.Exit(m.Run()) +} + // The modes the host is supposed to end up with, and the mode the -// stand-in ssh needs so that it can be run at all. +// stand-ins need so that they can be run at all. const ( directoryMode = 0o700 fileMode = 0o600 @@ -22,8 +44,21 @@ const ( ) // failingStatus is the status the stand-in ssh ends with when a test -// wants to see a status handed on. -const failingStatus = 7 +// wants to see a status handed on, and failedStatus is the status the +// tool itself ends with when something went wrong. +const ( + failingStatus = 7 + failedStatus = 1 +) + +// notADirectory is what a test puts where the .ssh directory belongs +// to make a .ssh that is listed but cannot be entered. +const notADirectory = "a file where the directory belongs\n" + +// missingIdentity is a path with no file at it, handed to sftp after +// the dashes so that ssh warns about it in the words of a missing +// file. +const missingIdentity = "/nonexistent/keyfunc-test-identity" // The host, and where on it the key ends up. const ( @@ -32,27 +67,133 @@ const ( keptIn = "authorized_keys" ) -// installer is a stand-in for the system ssh for the install command. -// It writes down what it was given and then runs the command meant for -// the host right here, with the home directory pointed at a directory -// standing in for the host's, so that what keyfunc sends can be -// watched doing its work. +// remoteCommand is the command the "to" tests hand ssh after the host. +const remoteCommand = "uptime" + +// The tool's own name, as it stands in the arguments a test hands to +// Main, the ssh subcommand both commands the tests here drive live +// under, and the one of those two these tests name most. +const ( + tool = "keyfunc" + subcommand = "ssh" + installing = "install" +) + +// The key line the example mnemonic gives at index 0, as it stands in +// an authorized_keys file. +const keyLine = vectorZero + " keyfunc/ssh/0\n" + +// marker is a variable set beside the mnemonic ones and expected to +// reach the stand-in, so a scrubbed environment is told apart from an +// empty one. +const marker = "KEYFUNC_TEST_MARKER" + +// installer is a stand-in for the system sftp for the install +// command. It writes down the arguments and every command of the +// batch it is given, echoes each command as sftp does, writes down its +// own environment when a test asks for it, and carries the commands out +// against a directory standing in for the host's home directory, so that +// what keyfunc sends can be watched doing its work. A command that +// begins with a dash may fail; any other failure ends the session, as it +// does in sftp's own batch mode. +// +// The listing and the two ways a get can fail are worded as the +// OpenSSH client words them, each naming the path the server expanded. +// A long listing (-n) writes each entry as the client does, its type +// first, so that a symlink shows as one. +// A listing fails one way when .ssh is not there and another when it is +// there but shut to the user; the first is the only failure read as a +// host with no file. A get fails one way for a file that is not there, +// which after a listing that came up empty is also read as no file, and +// another for a file that is there and cannot be read, which is a +// failure. A directory shut to the user is stood in for by mode 000, +// which the listing reads off the mode itself so that the test does not +// turn on the user it runs as, and one the user can enter but not write +// to by mode 500, which the put reads off the same way. An -i naming a +// file that is not here draws the warning ssh writes for it, which +// carries the wording of a missing file into a session that goes on to +// authenticate. const installer = ` -while [ $# -gt 1 ]; do - printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" - shift +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" +previous= +for argument in "$@"; do + printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" + if [ "$previous" = -i ] && [ ! -e "$argument" ]; then + printf 'Warning: Identity file %s not accessible: %s.\n' \ + "$argument" "No such file or directory" >&2 + fi + previous=$argument +done +home="$KEYFUNC_TEST_HOME" +while IFS= read -r line; do + printf 'sftp> %s\n' "$line" + printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH" + allowed=no + case "$line" in + -*) + line=${line#-} + allowed=yes + ;; + esac + eval "set -- $line" + worked=yes + case "$1" in + ls) + dir=$3 + if [ ! -e "$home/$dir" ]; then + worked=no + printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2 + elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then + worked=no + printf 'remote readdir("%s/"): Permission denied\n' \ + "$home/$dir" >&2 + else + for entry in "$home/$dir"/*; do + [ -e "$entry" ] || continue + name="$dir/$(basename "$entry")" + if [ "$2" = -n ]; then + printf '%s ? someone users 0 Oct 4 15:44 %s\n' \ + "$(stat -c '%A' "$entry")" "$name" + else + printf '%s\n' "$name" + fi + done + fi + ;; + get) + if [ ! -e "$home/$2" ]; then + worked=no + printf 'File "%s" not found.\n' "$home/$2" >&2 + elif ! cp "$home/$2" "$3" 2>/dev/null; then + worked=no + printf 'remote open "%s": Permission denied\n' "$home/$2" >&2 + fi + ;; + put) + if [ "$(stat -c '%a' "$(dirname "$home/$3")")" = 500 ]; then + worked=no + else + cp "$2" "$home/$3" 2>/dev/null || worked=no + fi + ;; + mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;; + chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;; + rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;; + esac + if [ "$worked" = no ] && [ "$allowed" = no ]; then + printf 'sftp: %s failed\n' "$1" >&2 + exit 1 + fi done -printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND" -HOME="$KEYFUNC_TEST_HOME" -export HOME -eval "$1" ` // caller is a stand-in for the system ssh for the to command. It // writes down the arguments it was given, notes the agent socket if -// there really is one at the path it was handed, and ends with the -// status the test asked for. +// there really is one at the path it was handed, writes down its own +// environment when a test asks for it, and ends with the status the +// test asked for. const caller = ` +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" for argument in "$@"; do printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" done @@ -63,24 +204,46 @@ fi exit "$KEYFUNC_TEST_STATUS" ` -// pretended is where a stand-in ssh writes down what it was asked to -// do. +// sleeper is a stand-in for the system ssh that notes the agent socket +// and then blocks, so a test can cancel the context while it is running +// and watch the tool take the agent down. The wait ends on its own only +// as a backstop, well after the test has cancelled and looked. +const sleeper = ` +socket=${2#IdentityAgent=} +if [ -S "$socket" ]; then + printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET" +fi +sleep 5 +` + +// stalled is a stand-in for the system sftp that starts a child, notes +// it has started, and then blocks, so a test can signal the tool while +// sftp is running. The child holds the output the tool reads sftp +// through, as the ssh that sftp starts does, and is started before the +// note so that it is there when the signal ends the shell and still +// holds that output afterwards. +const stalled = ` +sleep 5 & +touch "$KEYFUNC_TEST_STARTED" +wait +` + +// pretended is where a stand-in writes down what it was asked to do. type pretended struct { // home stands in for the home directory on the host. home string - // arguments holds what ssh was given before the command, one per - // line. + // arguments holds the arguments of every session, one per line. arguments string - // command holds what ssh was told to run on the host. - command string + // batch holds the commands of every session, one per line. + batch string } -func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { +func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - require.Equal(t, "added\n", run(t, "ssh", "install", host)) + require.Equal(t, "added\n", install(t, host)) directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) require.NoError(t, err) @@ -94,11 +257,30 @@ func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(fileMode), file.Mode().Perm()) - added := read(t, path) - require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added) + require.Equal(t, keyLine, read(t, path)) +} - require.Equal(t, "already present\n", run(t, "ssh", "install", host)) - require.Equal(t, added, read(t, path)) +func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "somebody else\n"+keyLine) + + require.Equal(t, "already present\n", install(t, host)) + require.Equal(t, "somebody else\n"+keyLine, read(t, path)) + + // The read and nothing after it: the tool did not connect again. + require.Equal(t, 1, connections(t, pretend)) +} + +func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "") + + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, keyLine, read(t, path)) } func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { @@ -106,51 +288,297 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { pretend := pretendHost(t) already := "ssh-ed25519 AAAAsomebodyelse somebody@else" + path := seed(t, pretend, already) - require.NoError(t, - os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), - ) - - path := filepath.Join(pretend.home, keptUnder, keptIn) - require.NoError(t, os.WriteFile(path, []byte(already), fileMode)) - - require.Equal(t, "added\n", run(t, "ssh", "install", host)) - require.Equal(t, - already+"\n"+vectorZero+" keyfunc/ssh/0\n", - read(t, path), - ) + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, already+"\n"+keyLine, read(t, path)) } -func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { +func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host) + require.Equal(t, "added\n", install(t, host)) + + sent := recorded(t, pretend.batch) + require.Len(t, sent, 6) + + // The name of the uploaded file is random, so it is read off the + // put and then looked for in the two commands that follow. + beside := strings.Fields(sent[3])[2] + require.True(t, + strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"), + ) + + // The listing fails on a host with no .ssh, so the get never runs; + // the write session then makes the directory and puts the file. + require.Equal(t, "ls -n .ssh", sent[0]) + require.Equal(t, "-mkdir .ssh", sent[1]) + require.Equal(t, "chmod 700 .ssh", sent[2]) + require.Equal(t, "put", strings.Fields(sent[3])[0]) + require.Equal(t, "chmod 600 "+beside, sent[4]) + require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5]) +} + +func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "Permission denied") + + // The read and nothing after it, and what was on the host is + // still what is on the host. + require.Equal(t, 1, connections(t, pretend)) + require.DirExists(t, unreadable) +} + +func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unlistable(t, pretend) + + // The listing is refused, which is not the same as no directory, so + // the tool writes nothing rather than treat a directory it cannot + // enter as a host with no file. + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "Permission denied") + + // The read and nothing after it: no second connection wrote a key. + require.Equal(t, 1, connections(t, pretend)) +} + +func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A file where .ssh belongs is listed and cannot be entered, which is + // how sftp sees a directory that can be read but not entered: the + // listing of .ssh comes up and the listing of .ssh/. finds nothing. + inTheWay := filepath.Join(pretend.home, keptUnder) + require.NoError(t, + os.WriteFile(inTheWay, []byte(notADirectory), fileMode), + ) + + printed, _, err := attempt(t, host) + require.ErrorIs(t, err, ssh.ErrCannotEnter) + require.Empty(t, printed) + + // The read and nothing after it: no upload was tried, and what was + // on the host is still what is on the host. + require.Equal(t, 1, connections(t, pretend)) + require.Equal(t, notADirectory, read(t, inTheWay)) +} + +func TestASymlinkedFileIsRefusedBeforeAnyUpload(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // The file the link points at, which the key would never reach. + target := filepath.Join(pretend.home, "keys") + require.NoError(t, + os.WriteFile(target, []byte("somebody else\n"), fileMode), + ) + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + + link := filepath.Join(directory, keptIn) + require.NoError(t, os.Symlink(target, link)) + + printed, _, err := attempt(t, host) + require.ErrorIs(t, err, ssh.ErrSymlink) + require.Empty(t, printed) + + // The read and nothing after it: no upload was tried, the link + // still points where it did, and what it points at is unchanged. + require.Equal(t, 1, connections(t, pretend)) + + pointsAt, err := os.Readlink(link) + require.NoError(t, err) + require.Equal(t, target, pointsAt) + require.Equal(t, "somebody else\n", read(t, target)) +} + +func TestAnArgumentBesideTheHostIsRefusedBeforeAnyConnection(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + runs := [][]string{ + {host, "frank@example.com"}, + {host, "2222"}, + {host, "frank@example.com", "--", "-P", "2222"}, + {"--", host}, + } + + for _, args := range runs { + printed, _, err := attempt(t, args...) + require.ErrorIs(t, err, ssh.ErrStrayArgument) + require.Empty(t, printed) + } + + // sftp was never started, so nothing was uploaded. + require.NoFileExists(t, pretend.arguments) +} + +func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A directory that is there but holds no file yet, made with a mode + // of its own so that a stray chmod would show. + const ownMode = 0o755 + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, ownMode)) + + require.Equal(t, "added\n", install(t, host)) + + // The key is added and the directory keeps the mode it had: the + // write session neither made it nor set its mode. + require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn))) + + kept, err := os.Stat(directory) + require.NoError(t, err) + require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm()) + + sent := recorded(t, pretend.batch) + require.NotContains(t, sent, "-mkdir .ssh") + require.NotContains(t, sent, "chmod 700 .ssh") +} + +func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + // ssh warns about an -i it cannot find in the words of a missing + // file, on a session that then authenticates perfectly well. That + // warning is not sftp reporting on authorized_keys, so the fetch + // failure is still a failure. + printed, said, err := attempt(t, host, "--", "-i", missingIdentity) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "No such file or directory") + require.Contains(t, said, "Permission denied") + + require.Equal(t, 1, connections(t, pretend)) + require.DirExists(t, unreadable) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{ + tool, subcommand, installing, host, "--", "-i", missingIdentity, + } + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A .ssh that can be listed and entered but not written to: the + // fetch finds no file in it, and then the put has nowhere to put + // anything, so the write session ends at the put. + const unwritable = 0o500 + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, unwritable)) + + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "put failed") + + // The put, after the three commands of the fetch, is the first and + // last command the write session got to, and the file it was + // uploading is the one the message names. + sent := recorded(t, pretend.batch) + require.Len(t, sent, 4) + require.Equal(t, "put", strings.Fields(sent[3])[0]) + require.Contains(t, err.Error(), strings.Fields(sent[3])[2]) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Empty(t, left) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{tool, subcommand, installing, host} + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestTheKeyLineIsNotSentAsACommand(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + install(t, host) require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") - require.NotContains(t, read(t, pretend.command), "ssh-ed25519") + require.NotContains(t, read(t, pretend.batch), "ssh-ed25519") } -func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { +func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host, "--", "-p", "2222") + install(t, host, "--", "-P", "2222") + // The same arguments twice over: adding a line takes two + // connections, one to fetch the file and one to write it back. + session := []string{"-b", "-", "-P", "2222", host} require.Equal(t, - []string{"-p", "2222", host}, + slices.Concat(session, session), recorded(t, pretend.arguments), ) } +func TestAProcessSFTPLeavesBehindDoesNotFailTheRun(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A session that works ends by leaving a child behind that holds + // sftp's output, as the master ssh leaves running for ControlPersist + // does under -v. + standIn(t, "sftp", installer+"sleep 5 &\n") + + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, keyLine, + read(t, filepath.Join(pretend.home, keptUnder, keptIn)), + ) +} + func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { t.Setenv(mnemonic.Variable, example()) arguments, noted := pretendCall(t) - _, err := execute(t, "ssh", "to", host, "uptime") + _, err := execute(t, subcommand, "to", host, remoteCommand) var passed ssh.StatusError @@ -159,7 +587,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { given := recorded(t, arguments) require.Equal(t, "-o", given[0]) - require.Equal(t, []string{host, "uptime"}, given[2:]) + require.Equal(t, []string{host, remoteCommand}, given[2:]) // The stand-in wrote the path down only because there really was // a socket there while it ran. @@ -177,11 +605,186 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { t.Cleanup(func() { os.Args = given }) - os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} + os.Args = []string{tool, subcommand, "to", host, remoteCommand} require.Equal(t, failingStatus, cli.Main()) } +func TestASignalTakesTheAgentDirectoryDown(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + // The three signals the tool handles, checked one after another. + signals := []struct { + name string + signal os.Signal + }{ + {"SIGTERM", syscall.SIGTERM}, + {"SIGINT", syscall.SIGINT}, + {"SIGHUP", syscall.SIGHUP}, + } + + for _, ending := range signals { + signalEndsTheTool(t, ending.name, ending.signal) + } +} + +// signalEndsTheTool runs the tool as a subprocess against a stand-in +// ssh that blocks, waits until the agent is up and ssh is running +// against it, sends the tool the signal, and requires the agent socket +// and its directory to be gone once the tool has ended. The subprocess +// goes through Main and the command's signal handling, so with that handling +// removed the signal kills the tool outright, no deferred cleanup runs, +// the directory is left behind, and the check fails. +func signalEndsTheTool(t *testing.T, name string, signal os.Signal) { + t.Helper() + + noted := filepath.Join(t.TempDir(), "socket") + t.Setenv("KEYFUNC_TEST_SOCKET", noted) + standIn(t, "ssh", sleeper) + + //nolint:gosec // the binary is this test's own, re-run as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], subcommand, "to", host, remoteCommand, + ) + + command.Env = append(os.Environ(), runAsTool+"=1") + require.NoError(t, command.Start()) + + // The stand-in notes the socket only once the agent is up and ssh + // is running against it, so this is where the signal lands. + socket := waitForSocket(t, noted) + + require.NoError(t, command.Process.Signal(signal)) + waitForTool(t, name, command) + + // The signal ended the tool, and its deferred cleanup still ran: + // the agent socket and its directory are gone. + require.NoDirExists(t, filepath.Dir(socket), name) +} + +// waitForTool waits for the subprocess to end, and fails the test if it +// does not end in time. +func waitForTool(t *testing.T, name string, command *exec.Cmd) { + t.Helper() + + done := make(chan error, 1) + go func() { done <- command.Wait() }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("the tool did not end after %s", name) + } +} + +// waitForSocket waits for the stand-in to write down the agent socket +// and gives back the path, which means the agent is up and ssh is +// running against it. +func waitForSocket(t *testing.T, noted string) string { + t.Helper() + + var socket string + + require.Eventually(t, func() bool { + content, err := os.ReadFile(noted) //nolint:gosec // test path + if err != nil { + return false + } + + socket = strings.TrimSpace(string(content)) + + return socket != "" + }, 5*time.Second, 5*time.Millisecond) + + return socket +} + +func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + signalEndsTheInstall(t, ending.String(), ending) + } +} + +// signalEndsTheInstall runs "ssh install" as a subprocess against a +// stand-in sftp that blocks, with a temporary directory of the test's +// own, waits until sftp is running, sends the tool the signal, and +// requires the tool to end within a second with status 1 and the +// working directory it made there to be gone. +func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) { + t.Helper() + + temporary := t.TempDir() + started := filepath.Join(t.TempDir(), "started") + t.Setenv("KEYFUNC_TEST_STARTED", started) + standIn(t, "sftp", stalled) + + //nolint:gosec // the binary is this test's own, re-run as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], subcommand, installing, host, + ) + + command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary) + require.NoError(t, command.Start()) + + // sftp is started only once the working directory has been made. + require.Eventually(t, func() bool { + _, err := os.Stat(started) + + return err == nil + }, 5*time.Second, 5*time.Millisecond) + + working, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Len(t, working, 1, name) + + sent := time.Now() + + require.NoError(t, command.Process.Signal(signal)) + waitForTool(t, name, command) + + // The child sftp started would hold sftp's output for seconds yet. + require.Less(t, time.Since(sent), time.Second, name) + require.Equal(t, failedStatus, command.ProcessState.ExitCode(), name) + + left, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Empty(t, left, name) +} + +func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendHost(t) + environment := recordEnvironment(t) + + install(t, host) + + mnemonicWithheld(t, read(t, environment)) +} + +func TestTheMnemonicIsNotHandedToSSH(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendCall(t) + environment := recordEnvironment(t) + + _, err := execute(t, subcommand, "to", host, remoteCommand) + + var passed ssh.StatusError + + require.ErrorAs(t, err, &passed) + + mnemonicWithheld(t, read(t, environment)) +} + // pretendHost puts the install stand-in on the path and gives back the // places it writes to. func pretendHost(t *testing.T) pretended { @@ -190,17 +793,110 @@ func pretendHost(t *testing.T) pretended { pretend := pretended{ home: t.TempDir(), arguments: filepath.Join(t.TempDir(), "arguments"), - command: filepath.Join(t.TempDir(), "command"), + batch: filepath.Join(t.TempDir(), "batch"), } t.Setenv("KEYFUNC_TEST_HOME", pretend.home) t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) - t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) - standIn(t, installer) + t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch) + standIn(t, "sftp", installer) return pretend } +// install runs the install command, requires it to have worked, and +// gives back what the tool itself printed. +func install(t *testing.T, args ...string) string { + t.Helper() + + printed, _, err := attempt(t, args...) + require.NoError(t, err) + + return printed +} + +// attempt runs the install command with the tool's own output kept +// apart from what the stand-in said, since the stand-in echoes its +// batch as sftp does. It gives back what the tool printed, what the +// stand-in said, and how the run ended. +func attempt(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + var printed, said bytes.Buffer + + root := cli.Root() + root.SetOut(&printed) + root.SetErr(&said) + root.SetArgs(slices.Concat([]string{subcommand, installing}, args)) + + err := root.ExecuteContext(t.Context()) + + return printed.String(), said.String(), err +} + +// seed puts an authorized_keys file on the stand-in host before the +// tool runs and gives back its path. +func seed(t *testing.T, pretend pretended, content string) string { + t.Helper() + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + + path := filepath.Join(directory, keptIn) + require.NoError(t, os.WriteFile(path, []byte(content), fileMode)) + + return path +} + +// unfetchable puts a directory where authorized_keys belongs on the +// stand-in host, which the stand-in can see but cannot fetch: that is +// how a file that is there and cannot be read looks from here. It +// gives back the path. +func unfetchable(t *testing.T, pretend pretended) string { + t.Helper() + + require.NoError(t, + os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), + ) + + path := filepath.Join(pretend.home, keptUnder, keptIn) + require.NoError(t, os.Mkdir(path, directoryMode)) + + return path +} + +// unlistable puts a .ssh on the stand-in host that is there but shut to +// the user, a directory of mode 000, and gives back its path. Its mode +// is put back before the temporary directory is cleared so that it can +// be. +func unlistable(t *testing.T, pretend pretended) string { + t.Helper() + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + require.NoError(t, os.Chmod(directory, 0)) + + t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) }) + + return directory +} + +// connections returns how many times the tool ran sftp, counted from +// the -b that opens each session's arguments. +func connections(t *testing.T, pretend pretended) int { + t.Helper() + + count := 0 + + for _, argument := range recorded(t, pretend.arguments) { + if argument == "-b" { + count++ + } + } + + return count +} + // pretendCall puts the to stand-in on the path and gives back the file // the arguments are written down in and the file the agent socket is // noted in. @@ -213,20 +909,21 @@ func pretendCall(t *testing.T) (string, string) { t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) t.Setenv("KEYFUNC_TEST_SOCKET", noted) t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) - standIn(t, caller) + standIn(t, "ssh", caller) return arguments, noted } -// standIn writes a stand-in for the system ssh and puts it first on -// the path, so that the tool finds it instead of the real one. -func standIn(t *testing.T, body string) { +// standIn writes a stand-in for one of the system programs and puts it +// first on the path, so that the tool finds it instead of the real +// one. +func standIn(t *testing.T, name, body string) { t.Helper() directory := t.TempDir() err := os.WriteFile( - filepath.Join(directory, "ssh"), + filepath.Join(directory, name), []byte("#!/bin/sh\n"+body), standInMode, ) require.NoError(t, err) @@ -236,6 +933,28 @@ func standIn(t *testing.T, body string) { ) } +// recordEnvironment asks the stand-in to write its environment down and +// gives back the file it writes it to. +func recordEnvironment(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "environment") + t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path) + + return path +} + +// mnemonicWithheld requires that neither mnemonic variable reached the +// stand-in and that the marker set beside them did, so an empty +// environment does not pass for a scrubbed one. +func mnemonicWithheld(t *testing.T, environment string) { + t.Helper() + + require.NotContains(t, environment, mnemonic.Variable+"=") + require.NotContains(t, environment, mnemonic.CommandVariable+"=") + require.Contains(t, environment, marker+"=") +} + // read returns what is in a file. func read(t *testing.T, path string) string { t.Helper() @@ -247,7 +966,7 @@ func read(t *testing.T, path string) string { return string(content) } -// recorded returns the arguments a stand-in wrote down, one per line. +// recorded returns the lines a stand-in wrote down. func recorded(t *testing.T, path string) []string { t.Helper() diff --git a/internal/cli/version_internal_test.go b/internal/cli/version_internal_test.go new file mode 100644 index 0000000..9c01c91 --- /dev/null +++ b/internal/cli/version_internal_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "runtime/debug" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveVersion(t *testing.T) { + t.Parallel() + + release := &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}} + local := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}} + empty := &debug.BuildInfo{} + + t.Run("stamped value wins over build info", func(t *testing.T) { + t.Parallel() + require.Equal(t, "v0.1.0", resolveVersion("v0.1.0", release)) + }) + + t.Run("go install reports the module version", func(t *testing.T) { + t.Parallel() + require.Equal(t, "v1.2.3", resolveVersion(devVersion, release)) + }) + + t.Run("a local build stays dev", func(t *testing.T) { + t.Parallel() + require.Equal(t, devVersion, resolveVersion(devVersion, local)) + }) + + t.Run("no version anywhere stays dev", func(t *testing.T) { + t.Parallel() + require.Equal(t, devVersion, resolveVersion(devVersion, empty)) + require.Equal(t, devVersion, resolveVersion(devVersion, nil)) + }) +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go index bd95906..cc98fa3 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -8,7 +8,7 @@ import ( "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" ) const ( diff --git a/internal/derive/derive_test.go b/internal/derive/derive_test.go index 75f8b70..3c35466 100644 --- a/internal/derive/derive_test.go +++ b/internal/derive/derive_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/derive" ) // application is the number the SSH key type uses. diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go index 85b34c0..c276074 100644 --- a/internal/mnemonic/mnemonic.go +++ b/internal/mnemonic/mnemonic.go @@ -10,8 +10,8 @@ import ( "os/exec" "strings" - bip39 "github.com/tyler-smith/go-bip39" "golang.org/x/term" + "sneak.berlin/go/keyfunc/internal/bip39" ) const ( @@ -104,10 +104,11 @@ func ask() (string, error) { return checked(string(typed)) } -// checked drops the surrounding whitespace and refuses a mnemonic that -// does not pass the BIP-39 checksum. +// checked joins the words with single spaces, whatever whitespace +// separated them, since the seed is computed over the string itself, +// and refuses a mnemonic that does not pass the BIP-39 checksum. func checked(words string) (string, error) { - words = strings.TrimSpace(words) + words = strings.Join(strings.Fields(words), " ") if !bip39.IsMnemonicValid(words) { return "", ErrChecksum diff --git a/internal/mnemonic/mnemonic_test.go b/internal/mnemonic/mnemonic_test.go index c8f155c..7924f90 100644 --- a/internal/mnemonic/mnemonic_test.go +++ b/internal/mnemonic/mnemonic_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // Two more mnemonics that pass the checksum, so a test can tell which diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go index 2ab8cb6..4415054 100644 --- a/internal/sshkey/sshkey_test.go +++ b/internal/sshkey/sshkey_test.go @@ -7,11 +7,11 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/derive" - "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/sshkey" ) // agentDirectoryMode is what the directory holding the agent socket diff --git a/package.json b/package.json new file mode 100644 index 0000000..d846c06 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "license": "MIT", + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index 8b1bb0f..d7d1668 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,13 +3,35 @@ # repo. Idempotent: every install is guarded by a check, so tools that # are already there are left alone. Base tooling comes from nix, apt, # brew, or apk, detected in that order, and nothing is assumed to be -# present. The linter is not installed here: it only ever runs inside -# the image built from Dockerfile.lint, so Docker is what is needed for -# it, and that is checked for rather than installed. +# present. Go is installed at the version the Dockerfile's Go image +# carries, from the official release archive, into ~/.local/go. Node is +# used directly if installed; otherwise it is installed at a pinned +# version via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh). The linter is not installed here: +# linting and testing run only as phases of the Dockerfile, so Docker is +# what is needed for them, and that is checked for rather than +# installed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the Go in the Dockerfile's golang image; the sha256 of each +# archive is in install_go. +GO_VERSION="1.26.8" + +# This script cannot change its caller's PATH, so script/fmt, +# script/fmt-check, script/precommit and the Makefile put this directory +# first on their own PATH, as is done here. +GO_DIR="$HOME/.local/go" +PATH="$GO_DIR/bin:$PATH" + +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + PKGMGR="" SUDO="" @@ -32,6 +54,9 @@ detect_pkgmgr() { if [ "$(id -u)" != "0" ]; then SUDO="sudo" fi + # This runs once, before the first install: a fresh host or + # runner image has no package lists yet. + $SUDO apt-get update fi } @@ -50,17 +75,139 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# True when the go first on PATH reports exactly GO_VERSION. No go, a go +# that fails, or any other output is a mismatch. +go_version_matches() { + out="$(go version 2>/dev/null)" || return 1 + case "$out" in + "go version go$GO_VERSION "*) return 0 ;; + *) return 1 ;; + esac +} + +install_go() { + # sha256 of the go1.26.8 archives at https://go.dev/dl/, 2026-10-04 + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + platform="linux-amd64" + sha256="d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b" + ;; + Linux-aarch64) + platform="linux-arm64" + sha256="211ffced9dcb9633a55eac6364816ec0ddd951389a740e88fa8b3337971bdda0" + ;; + Darwin-x86_64) + platform="darwin-amd64" + sha256="186be014105aa6542b767d2c6ed5cca10a0214bdff809ef1724022a8c7894150" + ;; + Darwin-arm64) + platform="darwin-arm64" + sha256="a012b25b571bd0138a03dcd25375ceba866fe5ca822f426d2c66a4de56fd3f4b" + ;; + *) + echo "bootstrap: no Go archive pinned for $(uname -s) $(uname -m)" >&2 + exit 1 + ;; + esac + if missing curl; then pkg_install curl curl curl curl; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/go.tar.gz" \ + "https://go.dev/dl/go${GO_VERSION}.${platform}.tar.gz" + verify_sha256 "$tmp/go.tar.gz" "$sha256" + # An archive unpacked over an older Go leaves a broken tree. + rm -rf "$GO_DIR" + mkdir -p "$GO_DIR" + tar -xzf "$tmp/go.tar.gz" -C "$GO_DIR" --strip-components=1 + rm -rf "$tmp" +} + +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + main() { cd "$ROOT" if missing git; then pkg_install git git git git; fi if missing make; then pkg_install gnumake make make make; fi - if missing go; then pkg_install go golang go go; fi + + if ! go_version_matches; then + install_go + hash -r + if ! go_version_matches; then + echo "bootstrap: $(command -v go) is not go$GO_VERSION after installing it" >&2 + exit 1 + fi + fi + go version go mod download + ensure_node + ensure_yarn + install_js_deps + if missing docker; then - echo "bootstrap: docker is not installed; make lint needs it" >&2 + echo "bootstrap: docker is not installed; make lint and make test need it" >&2 fi echo "bootstrap complete" diff --git a/script/cibuild b/script/cibuild index baad78e..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,10 @@ #!/bin/sh -# script/cibuild: run the CI build. The linter needs an image of its -# own, so it runs first; the Dockerfile then runs the formatting check, -# the tests and the build, so a green run here means make check is -# green. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -10,8 +12,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - "$SCRIPT_DIR/lint" - docker build . + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/fmt b/script/fmt index e95d111..c8598b2 100755 --- a/script/fmt +++ b/script/fmt @@ -1,12 +1,36 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes): the Go source with go fmt, then +# the Markdown files with prettier. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + +# 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() { cd "$ROOT" go fmt ./... + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 2e91588..b66223e 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -5,6 +5,28 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + +# 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() { cd "$ROOT" if [ -n "$(gofmt -l .)" ]; then @@ -12,6 +34,7 @@ main() { gofmt -l . exit 1 fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/lint b/script/lint index fe75dca..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,9 +1,13 @@ #!/bin/sh -# script/lint: run the linter. Linting only ever happens inside the -# image built from Dockerfile.lint, which pins the linter by hash, so -# the answer is the same on every machine and in CI. The linter runs as -# a build step of that image, so a complaint fails the build and no -# container is left behind. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. +# +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -11,7 +15,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --progress=plain -f Dockerfile.lint \ + docker build --no-cache \ + --target lint \ -t "$("$SCRIPT_DIR/projectname")-lint" . } diff --git a/script/precommit b/script/precommit index fe9775b..4794d57 100755 --- a/script/precommit +++ b/script/precommit @@ -7,6 +7,9 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + main() { cd "$ROOT" go mod tidy diff --git a/script/test b/script/test index 25c9896..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,13 +1,19 @@ #!/bin/sh -# script/test: run the test suite (vet first, verbose rerun on failure). +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - go vet ./... - go test -timeout 90s ./... || go test -timeout 90s -v ./... + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -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==