From 279cba6bcf4b415c4db2880b7b206a0dcb276153 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 7 Sep 2026 17:34:54 +0200 Subject: [PATCH 1/4] Skeleton, mnemonic input, derivation, and the ssh pub and priv commands (closes #1) The module, the script entrypoints and Makefile, Docker-only linting, the mnemonic sources in the specified order with their refusals, the BIP-85 derivation, and keyfunc ssh pub and priv with the README test vectors as tests. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing) --- .dockerignore | 3 + .editorconfig | 12 + .gitea/workflows/check.yml | 9 + .gitignore | 20 ++ .golangci.yml | 34 +++ Dockerfile | 26 ++ Dockerfile.lint | 15 + Makefile | 47 ++++ REPO_POLICIES.md | 421 +++++++++++++++++++++++++++++ go.mod | 26 ++ go.sum | 132 +++++++++ internal/cli/cli.go | 48 ++++ internal/cli/cli_test.go | 110 ++++++++ internal/cli/options/options.go | 43 +++ internal/cli/ssh/ssh.go | 127 +++++++++ internal/derive/derive.go | 71 +++++ internal/derive/derive_test.go | 61 +++++ internal/mnemonic/mnemonic.go | 117 ++++++++ internal/mnemonic/mnemonic_test.go | 87 ++++++ internal/sshkey/sshkey.go | 63 +++++ internal/sshkey/sshkey_test.go | 68 +++++ main.go | 12 + script/bootstrap | 69 +++++ script/check | 14 + script/cibuild | 17 ++ script/docker | 14 + script/fmt | 12 + script/fmt-check | 17 ++ script/install-precommit | 16 ++ script/lint | 18 ++ script/precommit | 19 ++ script/projectname | 12 + script/setup | 13 + script/test | 13 + 34 files changed, 1786 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitea/workflows/check.yml create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.lint create mode 100644 Makefile create mode 100644 REPO_POLICIES.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/options/options.go create mode 100644 internal/cli/ssh/ssh.go create mode 100644 internal/derive/derive.go create mode 100644 internal/derive/derive_test.go create mode 100644 internal/mnemonic/mnemonic.go create mode 100644 internal/mnemonic/mnemonic_test.go create mode 100644 internal/sshkey/sshkey.go create mode 100644 internal/sshkey/sshkey_test.go create mode 100644 main.go create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/projectname create mode 100755 script/setup create mode 100755 script/test diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d848e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.gitea +/keyfunc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2fe0ce0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..ee73864 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,9 @@ +name: check +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/cibuild diff --git a/.gitignore b/.gitignore index 7b936b0..7e0d436 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ +# The built binary /keyfunc + +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Environment / secrets +.env +.env.* +*.pem +*.key diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..26b1610 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +version: "2" + +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: all + disable: + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - depguard # Dependency allow/block lists + - 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 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e48ab65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# 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. + +# golang:1.26-alpine, 2026-09-07 +FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder + +RUN apk add --no-cache make git + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN make fmt-check +RUN make test +RUN make build + +# alpine:3.23, 2026-09-07 +FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 + +COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc + +ENTRYPOINT ["keyfunc"] diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..635c754 --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,15 @@ +# 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/Makefile b/Makefile new file mode 100644 index 0000000..af82436 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# Makefile targets are thin shims; the implementations live in script/ +# per the scripts-to-rule-them-all pattern. The one exception is build, +# 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)' + +.PHONY: default bootstrap setup build test lint fmt fmt-check check \ + docker cibuild hooks clean + +default: check + +bootstrap: + @script/bootstrap + +setup: + @script/setup + +build: + go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc . + +test: + @script/test + +lint: + @script/lint + +fmt: + @script/fmt + +fmt-check: + @script/fmt-check + +check: + @script/check + +docker: + @script/docker + +cibuild: + @script/cibuild + +hooks: + @script/install-precommit + +clean: + rm -f keyfunc diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..fad388c --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,421 @@ +--- +title: Repository Policies +last_modified: 2026-08-19 +--- + +This document covers repository structure, tooling, and workflow standards. Code +style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) +- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) +- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) +- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) + +--- + +- Cross-project documentation (such as this file) must include + `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync + with the authoritative source as policies evolve. + +- **ALL external references must be pinned by cryptographic hash.** This + includes Docker base images, Go modules, npm packages, GitHub Actions, and + anything else fetched from a remote source. Version tags (`@v4`, `@latest`, + `:3.21`, etc.) are server-mutable and therefore remote code execution + vulnerabilities. The ONLY acceptable way to reference an external dependency + is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm + integrity hash in lockfile, GitHub Actions `@`). No exceptions. + This also means never `curl | bash` to install tools like pyenv, nvm, rustup, + etc. Instead, download a specific release archive from GitHub, verify its hash + (hardcoded in the Dockerfile or script), and only then install. Unverified + install scripts are arbitrary remote code execution. This is the single most + important rule in this document. Double-check every external reference in + every file before committing. There are zero exceptions to this rule. + +- Every repo with software must have a root `Makefile` with these targets: + `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), + `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile + is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an executable + script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, + `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, + `script/docker`), and the Makefile targets are thin shims that call them. The + scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in + minimal containers (e.g. alpine images have no bash); locate the repo root + with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From + the standard's canonical set we use `bootstrap`, `setup` (make the repo ready + for development after a fresh clone: runs `bootstrap`, then + `install-precommit`, plus any repo-specific initialization), `test`, and + `cibuild`. `script/bootstrap` installs all dependencies idempotently and + assumes nothing is present: base tools come from nix, apt, brew, or apk + (detected in that order; apt runs noninteractive). For node it uses the + installed node if present; otherwise it installs a PINNED node version via + nvm, first installing nvm itself if missing — from a hash-verified GitHub + release archive (never `curl | sh`), with bash installed as an explicit + 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 + `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). + +- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) + instead of invoking the underlying tools directly. The Makefile is the single + source of truth for how these operations are run. + +- The Makefile is authoritative documentation for how the repo is used. Beyond + the required targets above, it should have targets for every common operation: + running a local development server (`make run`, `make dev`), re-initializing + or migrating the database (`make db-reset`, `make migrate`), building + artifacts (`make build`), generating code, seeding data, or anything else a + developer would do regularly. If someone checks out the repo and types + `make`, they should see every meaningful operation available. A new + 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. + +- **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. + + The standard pattern for a Go repo Dockerfile is: + + ```dockerfile + # Lint stage — fast feedback on formatting and lint issues + # 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 + + # Build stage + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + 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 + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage + 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. + - 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 + 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. + +- 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. + +- Use platform-standard formatters: `black` for Python, `prettier` for + JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with + two exceptions: four-space indents (except Go), and `proseWrap: always` for + Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, + HTML, CSS) should also have `.prettierrc` and `.prettierignore`. + +- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local + testing is not possible in the repo, `script/precommit` may skip `script/test` + and run only `script/lint` and `script/fmt-check`. The hook is installed by + `script/install-precommit`; the Makefile must provide a `make hooks` target + that shims to it. + +- All repos with software must have tests that run via the platform-standard + test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful + tests exist yet, add the most minimal test possible — e.g. importing the + module under test to verify it compiles/parses. There is no excuse for + `make test` to be a no-op. + +- `make test` must complete in under 60 seconds. That is the hard cap, and a + 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. + +- **`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: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + ``` + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the first run + already proved the tests are broken, so the build must not pass even if a + flaky test happens to succeed on the second attempt. The rerun exists solely + for diagnostic output. + +- Docker builds must complete in under 5 minutes. + +- `make check` must not modify any files in the repo. Tests may use temporary + directories. + +- `main` must always pass `make check`, no exceptions. + +- Never commit secrets. `.env` files, credentials, API keys, and private keys + 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. + +- **No build artifacts in version control.** Code-derived data (compiled + bundles, minified output, generated assets) must never be committed to the + repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) + should generate these at build time. Notable exception: Go protobuf generated + files (`.pb.go`) ARE committed because repos need to work with `go get`, which + downloads code but does not execute code generation. + +- Never use `git add -A` or `git add .`. Always stage files explicitly by name. + +- Never force-push to `main`. + +- Make all changes on a feature branch. You can do whatever you want on a + feature branch. + +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + 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`. + +- When pinning images or packages by hash, add a comment above the reference + with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable with + `PORT`. + +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to + an explicit allowlist of known origins. Wildcard (`*`) is acceptable + only for public, unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, file paths, + or other implementation details to the client. Return generic error + messages in production; detailed errors only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + +- `README.md` is the primary documentation. Required sections: + - **Description**: First line must include the project name, purpose, + category (web server, SPA, CLI tool, etc.), license, and author. Example: + "µPaaS is an MIT-licensed Go web application by @sneak that receives + git-frontend webhooks and deploys applications via Docker in realtime." + - **Getting Started**: Copy-pasteable install/usage code block. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. + - **Rationale**: Why does this exist? + - **Design**: How is the program structured? + - **TODO**: Update meticulously, even between commits. When planning, put + the todo list in the README so a new agent can pick up where the last one + left off. + - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a + `LICENSE` file in the repo root and a License section in the README. + - **Author**: [@sneak](https://sneak.berlin). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before + committing. + +- Use SemVer. + +- Database migrations live in `internal/db/migrations/` and must be embedded in + the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations + tracking table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). + There is no installed base to migrate. Edit `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema change. + Never edit existing migrations after release. + +- All repos should have an `.editorconfig` enforcing the project's indentation + 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: + - `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 + `cmd/` + - `configs/` — configuration templates and examples + - `deploy/` — deployment manifests (k8s, compose, terraform) + - `docs/` — documentation and markdown (README.md stays in root) + - `internal/` — Go internal packages + - `internal/db/migrations/` — database migrations + - `pkg/` — Go library packages + - `share/` — systemd units, data files + - `static/` — static assets (images, fonts, etc.) + - `web/` — web frontend source + +- When setting up a new repo, files from the `prompts` repo may be used as + templates. Fetch them from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. + +- New repos must contain at minimum: + - `README.md`, `.git`, `.gitignore`, `.editorconfig` + - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) + - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) + - `Dockerfile`, `.dockerignore` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7934093 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module git.eeqj.de/sneak/keyfunc + +go 1.26 + +require ( + 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 +) + +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 + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bc30ce --- /dev/null +++ b/go.sum @@ -0,0 +1,132 @@ +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/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/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/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/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= +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/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..b901700 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,48 @@ +// Package cli builds the command tree and runs it. +package cli + +import ( + "fmt" + "os" + + "git.eeqj.de/sneak/keyfunc/internal/cli/options" + "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" + "github.com/spf13/cobra" +) + +// Version is what --version prints. The build sets it. +// +//nolint:gochecknoglobals // set at build time with -ldflags +var Version = "dev" + +// Root returns the whole command tree. +func Root() *cobra.Command { + 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, + SilenceUsage: true, + SilenceErrors: true, + } + + options.Add(root) + root.AddCommand(ssh.Command()) + + return root +} + +// Main runs the tool and returns the status the process should exit +// with. +func Main() int { + err := Root().Execute() + if err != nil { + fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error()) + + return 1 + } + + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..75b3df8 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,110 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "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" + "golang.org/x/crypto/ssh" +) + +// The two lines the README says the example mnemonic produces. +const ( + vectorZero = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZOtOczrc/7CQytc" + + "uFwt7s4r8KjkZWkwjLZWBaFKD+7" + vectorOne = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEWY8+/gmHYVC4u0Y" + + "0I4FKs+eVUulTPHfk9VtXw1tMF" +) + +// example returns the mnemonic the README gives its test vectors for: +// eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +func TestTheReadmeTestVectors(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + require.Equal(t, + vectorZero+" keyfunc/ssh/0", + strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")), + ) + require.Equal(t, + vectorOne+" keyfunc/ssh/1", + strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")), + ) +} + +func TestTheCommentCanBeChosen(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + line := strings.TrimSpace(run(t, "ssh", "pub", "--comment", "mine")) + require.Equal(t, vectorZero+" mine", line) +} + +func TestThePrivateKeyMatchesThePublicOne(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + block := run(t, "ssh", "priv", "-n", "1") + require.True(t, + strings.HasPrefix(block, "-----BEGIN OPENSSH PRIVATE KEY-----"), + ) + + parsed, err := ssh.ParsePrivateKey([]byte(block)) + require.NoError(t, err) + + back := strings.TrimSpace( + string(ssh.MarshalAuthorizedKey(parsed.PublicKey())), + ) + require.Equal(t, vectorOne, back) +} + +func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + out, err := execute(t, "ssh", "pub", "-n", "2147483648") + require.ErrorIs(t, err, derive.ErrIndexTooLarge) + require.Empty(t, out) +} + +func TestTheMnemonicCommandIsUsed(t *testing.T) { + t.Setenv(mnemonic.Variable, "") + + line := strings.TrimSpace(run(t, + "ssh", "pub", + "--mnemonic-command", "printf '%s\\n' '"+example()+"'", + )) + require.Equal(t, vectorZero+" keyfunc/ssh/0", line) +} + +// run executes the tool with the given arguments and returns what it +// wrote to standard output. +func run(t *testing.T, args ...string) string { + t.Helper() + + out, err := execute(t, args...) + require.NoError(t, err) + + return out +} + +// execute runs the tool and returns both what it wrote and how it +// ended. +func execute(t *testing.T, args ...string) (string, error) { + t.Helper() + + var out bytes.Buffer + + root := cli.Root() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + + err := root.ExecuteContext(t.Context()) + + return out.String(), err +} diff --git a/internal/cli/options/options.go b/internal/cli/options/options.go new file mode 100644 index 0000000..cfad875 --- /dev/null +++ b/internal/cli/options/options.go @@ -0,0 +1,43 @@ +// Package options holds the flags that every command has and reads +// them back. +package options + +import ( + "fmt" + + "git.eeqj.de/sneak/keyfunc/internal/mnemonic" + "github.com/spf13/cobra" +) + +// Add gives a command the flags that every command has. They are +// persistent, so every command below it has them too. +func Add(cmd *cobra.Command) { + cmd.PersistentFlags().String( + "mnemonic-command", "", + "shell command whose output is the mnemonic", + ) + cmd.PersistentFlags().Uint32P( + "index", "n", 0, + "which key to derive", + ) +} + +// Index returns the key index the user asked for. +func Index(cmd *cobra.Command) (uint32, error) { + index, err := cmd.Flags().GetUint32("index") + if err != nil { + return 0, fmt.Errorf("reading the index: %w", err) + } + + return index, nil +} + +// Mnemonic returns the mnemonic, from the first source that has one. +func Mnemonic(cmd *cobra.Command) (string, error) { + command, err := cmd.Flags().GetString("mnemonic-command") + if err != nil { + return "", fmt.Errorf("reading the mnemonic command: %w", err) + } + + return mnemonic.Read(cmd.Context(), command) +} diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go new file mode 100644 index 0000000..f707214 --- /dev/null +++ b/internal/cli/ssh/ssh.go @@ -0,0 +1,127 @@ +// Package ssh groups the commands that derive ed25519 SSH keys. +package ssh + +import ( + "fmt" + + "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" +) + +// Command returns the ssh command and everything under it. +func Command() *cobra.Command { + group := &cobra.Command{ + Use: "ssh", + Short: "derive ed25519 SSH keys", + } + + group.AddCommand(public(), private()) + + return group +} + +// public returns the command that prints the authorized_keys line. +func public() *cobra.Command { + cmd := &cobra.Command{ + Use: "pub", + Short: "print the public key as an authorized_keys line", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + line, err := key.Line(comment) + if err != nil { + return err + } + + return write(cmd, line+"\n") + }, + } + + addComment(cmd) + + return cmd +} + +// private returns the command that prints the private key. +func private() *cobra.Command { + cmd := &cobra.Command{ + Use: "priv", + Short: "print the unencrypted private key in OpenSSH format", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + block, err := key.Block(comment) + if err != nil { + return err + } + + return write(cmd, block) + }, + } + + addComment(cmd) + + return cmd +} + +// write sends the text to wherever the command's output goes. +func write(cmd *cobra.Command, text string) error { + _, err := fmt.Fprint(cmd.OutOrStdout(), text) + if err != nil { + return fmt.Errorf("writing the key: %w", err) + } + + return nil +} + +// addComment gives a command its comment flag. +func addComment(cmd *cobra.Command) { + cmd.Flags().String( + "comment", "", + "comment on the key; keyfunc/ssh/ when not given", + ) +} + +// derived returns the key for this run and the comment to put on it. +func derived(cmd *cobra.Command) (*sshkey.Key, string, error) { + index, err := options.Index(cmd) + if err != nil { + return nil, "", err + } + + words, err := options.Mnemonic(cmd) + if err != nil { + return nil, "", err + } + + material, err := derive.Bytes(words, sshkey.Application, index) + if err != nil { + return nil, "", err + } + + key, err := sshkey.New(material) + if err != nil { + return nil, "", err + } + + comment, err := cmd.Flags().GetString("comment") + if err != nil { + return nil, "", fmt.Errorf("reading the comment: %w", err) + } + + if comment == "" { + comment = fmt.Sprintf("keyfunc/ssh/%d", index) + } + + return key, comment, nil +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go new file mode 100644 index 0000000..fe9176c --- /dev/null +++ b/internal/derive/derive.go @@ -0,0 +1,71 @@ +// Package derive turns a mnemonic into the bytes a key is made from. +package derive + +import ( + "errors" + "fmt" + + "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" +) + +const ( + // purpose is the number BIP-85 reserves for itself. + purpose = 83696968 + + // Size is how many bytes every key type is given. + Size = 32 + + // MaxIndex is the largest key index there is. Every element of + // the path is hardened, and a hardened BIP-32 child index stops + // here. + MaxIndex = 1<<31 - 1 +) + +// ErrIndexTooLarge is returned for a key index above MaxIndex. Such an +// index has no hardened child to derive, so there is no key to give +// back rather than a key nobody else would reproduce. +var ErrIndexTooLarge = errors.New("the key index is too large") + +// Path returns the derivation path for an application number and a key +// index. +func Path(application, index uint32) string { + return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index) +} + +// Bytes returns the bytes for an application number and a key index. +// The mnemonic becomes a seed with an empty passphrase, the seed +// becomes a master key, the master key gives BIP-85 entropy at the +// path, and the entropy seeds the generator the bytes are read from. +// An index above MaxIndex is refused before any of that happens. +func Bytes(words string, application, index uint32) ([]byte, error) { + if index > MaxIndex { + return nil, fmt.Errorf( + "%w: %d is above %d", + ErrIndexTooLarge, index, MaxIndex, + ) + } + + seed := bip39.NewSeed(words, "") + + master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + if err != nil { + return nil, fmt.Errorf("making the master key: %w", err) + } + + entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index)) + if err != nil { + return nil, fmt.Errorf("deriving entropy: %w", err) + } + + out := make([]byte, Size) + + _, err = bip85.NewBIP85DRNG(entropy).Read(out) + if err != nil { + return nil, fmt.Errorf("reading derived bytes: %w", err) + } + + return out, nil +} diff --git a/internal/derive/derive_test.go b/internal/derive/derive_test.go new file mode 100644 index 0000000..75f8b70 --- /dev/null +++ b/internal/derive/derive_test.go @@ -0,0 +1,61 @@ +package derive_test + +import ( + "bytes" + "strings" + "testing" + + "git.eeqj.de/sneak/keyfunc/internal/derive" + "github.com/stretchr/testify/require" +) + +// application is the number the SSH key type uses. +const application = 838372 + +// example returns the mnemonic every BIP-39 document uses to show its +// test vectors: eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +func TestPathIsTheOneTheSpecificationGives(t *testing.T) { + t.Parallel() + + require.Equal(t, "m/83696968'/838372'/3'", derive.Path(application, 3)) +} + +func TestEveryIndexGivesItsOwnBytes(t *testing.T) { + t.Parallel() + + first, err := derive.Bytes(example(), application, 0) + require.NoError(t, err) + require.Len(t, first, derive.Size) + + second, err := derive.Bytes(example(), application, 1) + require.NoError(t, err) + require.Len(t, second, derive.Size) + + require.False(t, bytes.Equal(first, second)) +} + +func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { + t.Parallel() + + _, err := derive.Bytes(example(), application, derive.MaxIndex+1) + require.ErrorIs(t, err, derive.ErrIndexTooLarge) + + _, err = derive.Bytes(example(), application, derive.MaxIndex) + require.NoError(t, err) +} + +func TestTheSameInputAlwaysGivesTheSameBytes(t *testing.T) { + t.Parallel() + + once, err := derive.Bytes(example(), application, 7) + require.NoError(t, err) + + again, err := derive.Bytes(example(), application, 7) + require.NoError(t, err) + + require.Equal(t, once, again) +} diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go new file mode 100644 index 0000000..85b34c0 --- /dev/null +++ b/internal/mnemonic/mnemonic.go @@ -0,0 +1,117 @@ +// Package mnemonic finds the mnemonic the keys are derived from. +package mnemonic + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + bip39 "github.com/tyler-smith/go-bip39" + "golang.org/x/term" +) + +const ( + // CommandVariable holds a shell command whose output is the + // mnemonic. + CommandVariable = "KEYFUNC_MNEMONIC_COMMAND" + + // Variable holds the mnemonic itself. + Variable = "KEYFUNC_MNEMONIC" +) + +// ErrMissing is returned when there is nowhere left to look and +// standard input is not a terminal, so there is nobody to ask. +var ErrMissing = errors.New( + "no mnemonic given and standard input is not a terminal", +) + +// ErrChecksum is returned for a mnemonic that fails the BIP-39 +// checksum. +var ErrChecksum = errors.New("the mnemonic fails its BIP-39 checksum") + +// Read returns the mnemonic. The command given on the command line is +// used first; then the command in KEYFUNC_MNEMONIC_COMMAND; then the +// mnemonic in KEYFUNC_MNEMONIC; then a prompt on the terminal with +// echo turned off. The first source that has one wins. +func Read(ctx context.Context, command string) (string, error) { + if command == "" { + command = os.Getenv(CommandVariable) + } + + if command != "" { + words, err := run(ctx, command) + if err != nil { + return "", err + } + + return checked(words) + } + + if words := os.Getenv(Variable); words != "" { + return checked(words) + } + + return ask() +} + +// run executes the command with sh and returns its standard output. +// If the command fails, its standard error becomes part of the error. +func run(ctx context.Context, command string) (string, error) { + //nolint:gosec // running the user's own command is the point + shell := exec.CommandContext(ctx, "sh", "-c", command) + + var complaint bytes.Buffer + + shell.Stderr = &complaint + + out, err := shell.Output() + if err != nil { + said := strings.TrimSpace(complaint.String()) + if said == "" { + return "", fmt.Errorf("the mnemonic command failed: %w", err) + } + + return "", fmt.Errorf( + "the mnemonic command failed: %w: %s", err, said, + ) + } + + return string(out), nil +} + +// ask prompts on the terminal with echo turned off. +func ask() (string, error) { + fd := int(os.Stdin.Fd()) + + if !term.IsTerminal(fd) { + return "", ErrMissing + } + + fmt.Fprint(os.Stderr, "mnemonic: ") + + typed, err := term.ReadPassword(fd) + + fmt.Fprintln(os.Stderr) + + if err != nil { + return "", fmt.Errorf("reading the mnemonic: %w", err) + } + + return checked(string(typed)) +} + +// checked drops the surrounding whitespace and refuses a mnemonic that +// does not pass the BIP-39 checksum. +func checked(words string) (string, error) { + words = strings.TrimSpace(words) + + if !bip39.IsMnemonicValid(words) { + return "", ErrChecksum + } + + return words, nil +} diff --git a/internal/mnemonic/mnemonic_test.go b/internal/mnemonic/mnemonic_test.go new file mode 100644 index 0000000..c8f155c --- /dev/null +++ b/internal/mnemonic/mnemonic_test.go @@ -0,0 +1,87 @@ +package mnemonic_test + +import ( + "strings" + "testing" + + "git.eeqj.de/sneak/keyfunc/internal/mnemonic" + "github.com/stretchr/testify/require" +) + +// Two more mnemonics that pass the checksum, so a test can tell which +// source an answer came from. +const ( + fromCommandVariable = "legal winner thank year wave sausage worth " + + "useful legal winner thank yellow" + fromVariable = "letter advice cage absurd amount doctor acoustic " + + "avoid letter advice cage above" +) + +// example returns the mnemonic every BIP-39 document uses to show its +// test vectors: eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +// broken returns a mnemonic whose last word does not match the rest. +func broken() string { + return strings.TrimSpace(strings.Repeat("abandon ", 12)) +} + +// prints builds a shell command that writes the given words padded +// with spaces, so the test also shows that the padding is dropped. +func prints(words string) string { + return "printf ' %s \\n' '" + words + "'" +} + +func TestTheCommandOnTheCommandLineWins(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable)) + t.Setenv(mnemonic.Variable, fromVariable) + + words, err := mnemonic.Read(t.Context(), prints(example())) + require.NoError(t, err) + require.Equal(t, example(), words) +} + +func TestTheCommandInTheEnvironmentComesNext(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable)) + t.Setenv(mnemonic.Variable, fromVariable) + + words, err := mnemonic.Read(t.Context(), "") + require.NoError(t, err) + require.Equal(t, fromCommandVariable, words) +} + +func TestTheMnemonicInTheEnvironmentComesLast(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "") + t.Setenv(mnemonic.Variable, " "+fromVariable+" ") + + words, err := mnemonic.Read(t.Context(), "") + require.NoError(t, err) + require.Equal(t, fromVariable, words) +} + +func TestAFailingCommandSaysWhatWentWrong(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "") + t.Setenv(mnemonic.Variable, "") + + _, err := mnemonic.Read(t.Context(), "echo nothing here >&2; exit 3") + require.Error(t, err) + require.Contains(t, err.Error(), "nothing here") +} + +func TestABadChecksumIsRefused(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "") + t.Setenv(mnemonic.Variable, broken()) + + _, err := mnemonic.Read(t.Context(), "") + require.ErrorIs(t, err, mnemonic.ErrChecksum) +} + +func TestNothingToReadAndNobodyToAsk(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "") + t.Setenv(mnemonic.Variable, "") + + _, err := mnemonic.Read(t.Context(), "") + require.ErrorIs(t, err, mnemonic.ErrMissing) +} diff --git a/internal/sshkey/sshkey.go b/internal/sshkey/sshkey.go new file mode 100644 index 0000000..7e66bae --- /dev/null +++ b/internal/sshkey/sshkey.go @@ -0,0 +1,63 @@ +// Package sshkey turns derived bytes into an ed25519 SSH key. +package sshkey + +import ( + "crypto/ed25519" + "encoding/pem" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" +) + +// Application is the number this key type occupies in the derivation +// path. It spells SSH the way BIP-85 spells RSA, as the ASCII codes of +// the letters written out. +const Application = 838372 + +// ErrSize is returned when the derived bytes are not the length an +// ed25519 seed has to be. +var ErrSize = errors.New("an ed25519 key needs 32 derived bytes") + +// Key is one ed25519 SSH key. +type Key struct { + private ed25519.PrivateKey +} + +// New makes a key whose ed25519 seed is the derived bytes. +func New(derived []byte) (*Key, error) { + if len(derived) != ed25519.SeedSize { + return nil, fmt.Errorf("%w, got %d", ErrSize, len(derived)) + } + + return &Key{private: ed25519.NewKeyFromSeed(derived)}, nil +} + +// Line returns the public key as one authorized_keys line, without a +// trailing newline. +func (k *Key) Line(comment string) (string, error) { + public, err := ssh.NewPublicKey(k.private.Public()) + if err != nil { + return "", fmt.Errorf("encoding the public key: %w", err) + } + + line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(public))) + + if comment != "" { + line += " " + comment + } + + return line, nil +} + +// Block returns the unencrypted private key in the OpenSSH format that +// ssh reads, ending in a newline. +func (k *Key) Block(comment string) (string, error) { + block, err := ssh.MarshalPrivateKey(k.private, comment) + if err != nil { + return "", fmt.Errorf("encoding the private key: %w", err) + } + + return string(pem.EncodeToMemory(block)), nil +} diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go new file mode 100644 index 0000000..02a40c9 --- /dev/null +++ b/internal/sshkey/sshkey_test.go @@ -0,0 +1,68 @@ +package sshkey_test + +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" +) + +// example returns the mnemonic every BIP-39 document uses to show its +// test vectors: eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +func TestTooFewBytesAreRefused(t *testing.T) { + t.Parallel() + + _, err := sshkey.New([]byte("short")) + require.ErrorIs(t, err, sshkey.ErrSize) +} + +func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) { + t.Parallel() + + key := forIndex(t, 0) + + line, err := key.Line("hello") + require.NoError(t, err) + require.True(t, strings.HasPrefix(line, "ssh-ed25519 ")) + require.True(t, strings.HasSuffix(line, " hello")) +} + +func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) { + t.Parallel() + + key := forIndex(t, 0) + + line, err := key.Line("") + require.NoError(t, err) + + block, err := key.Block("a comment") + require.NoError(t, err) + + parsed, err := ssh.ParsePrivateKey([]byte(block)) + require.NoError(t, err) + + back := strings.TrimSpace( + string(ssh.MarshalAuthorizedKey(parsed.PublicKey())), + ) + require.Equal(t, line, back) +} + +// forIndex derives the key for one index. +func forIndex(t *testing.T, index uint32) *sshkey.Key { + t.Helper() + + material, err := derive.Bytes(example(), sshkey.Application, index) + require.NoError(t, err) + + key, err := sshkey.New(material) + require.NoError(t, err) + + return key +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..90d87cc --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +// Command keyfunc derives key pairs from a BIP-39 mnemonic. +package main + +import ( + "os" + + "git.eeqj.de/sneak/keyfunc/internal/cli" +) + +func main() { + os.Exit(cli.Main()) +} diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..8b1bb0f --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,69 @@ +#!/bin/sh +# script/bootstrap: install everything needed to build and develop this +# 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. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +PKGMGR="" +SUDO="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +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 + + go mod download + + if missing docker; then + echo "bootstrap: docker is not installed; make lint needs it" >&2 + fi + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..3e1778c --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..baad78e --- /dev/null +++ b/script/cibuild @@ -0,0 +1,17 @@ +#!/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. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/lint" + docker build . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..9b9ea86 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..e95d111 --- /dev/null +++ b/script/fmt @@ -0,0 +1,12 @@ +#!/bin/sh +# script/fmt: format all files (writes). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + go fmt ./... +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..2e91588 --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,17 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). Same scope as +# script/fmt, but fails instead of writing. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + if [ -n "$(gofmt -l .)" ]; then + echo "Files need formatting:" + gofmt -l . + exit 1 + fi +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..723bae1 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,16 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our own extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + hook=".git/hooks/pre-commit" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook" + chmod +x "$hook" + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..fe75dca --- /dev/null +++ b/script/lint @@ -0,0 +1,18 @@ +#!/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. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --progress=plain -f Dockerfile.lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..fe9775b --- /dev/null +++ b/script/precommit @@ -0,0 +1,19 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Go extras run first: go mod tidy and go fmt, failing the +# commit if they change go.mod or go.sum. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + go fmt ./... + git diff --exit-code -- go.mod go.sum || + { echo "go mod tidy changed files; stage and retry" >&2; exit 1; } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..317ea3b --- /dev/null +++ b/script/projectname @@ -0,0 +1,12 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our own +# extension to scripts-to-rule-them-all. Other scripts that need the +# name (e.g. script/docker) call this, so they can stay identical +# across all repos. +set -eu + +main() { + echo "keyfunc" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..6f9724c --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies (script/bootstrap) and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..25c9896 --- /dev/null +++ b/script/test @@ -0,0 +1,13 @@ +#!/bin/sh +# script/test: run the test suite (vet first, verbose rerun on failure). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + go vet ./... + go test -timeout 90s ./... || go test -timeout 90s -v ./... +} + +main "$@" -- 2.49.1 From d69bed722a9a4a2f886214e5aea6200b456f46d4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 7 Sep 2026 18:05:08 +0200 Subject: [PATCH 2/4] The mnemonic command: child mnemonics (closes #4) keyfunc mnemonic derives a 12, 18 or 24 word child mnemonic through BIP-85's own mnemonic application, with the specification's test vectors as tests. One review round, passed with no findings; the reviewer reproduced the vectors from an implementation written from the specification alone. Model: opus-5 (implementation and review); fable-5-1 (landing) --- internal/childmnemonic/childmnemonic.go | 63 ++++++++++ internal/childmnemonic/childmnemonic_test.go | 117 +++++++++++++++++++ internal/cli/cli.go | 3 +- internal/cli/cli_test.go | 30 +++++ internal/cli/mnemonic/mnemonic.go | 65 +++++++++++ internal/derive/derive.go | 41 +++++-- 6 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 internal/childmnemonic/childmnemonic.go create mode 100644 internal/childmnemonic/childmnemonic_test.go create mode 100644 internal/cli/mnemonic/mnemonic.go diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go new file mode 100644 index 0000000..f795f1a --- /dev/null +++ b/internal/childmnemonic/childmnemonic.go @@ -0,0 +1,63 @@ +// Package childmnemonic derives a mnemonic from another mnemonic. +package childmnemonic + +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" +) + +// english is the number BIP-85 gives the English word list. +const english = 0 + +// The lengths a child mnemonic may have. BIP-85 also allows 15 and 21 +// words; these three are the ones this tool offers. +const ( + twelve = 12 + eighteen = 18 + twentyFour = 24 +) + +// DefaultWords is how long a child mnemonic is when the user does not +// say. +const DefaultWords = twelve + +// ErrWordCount is returned for a length this tool does not offer. +var ErrWordCount = errors.New("a child mnemonic has 12, 18 or 24 words") + +// Derive returns the English child mnemonic of that many words at that +// key index, taken from the master key with BIP-85's own mnemonic +// application, number 39. The words come straight from the BIP-85 +// entropy, cut to the length the word count needs, rather than from +// the generator the other key types read their bytes from. +func Derive( + master *hdkeychain.ExtendedKey, + words, index uint32, +) (string, error) { + switch words { + case twelve, eighteen, twentyFour: + default: + return "", fmt.Errorf("%w, not %d", ErrWordCount, words) + } + + err := derive.CheckIndex(index) + if err != nil { + return "", err + } + + entropy, err := bip85.DeriveBIP39Entropy(master, english, words, index) + if err != nil { + return "", fmt.Errorf("deriving entropy: %w", err) + } + + child, err := bip39.NewMnemonic(entropy) + if err != nil { + return "", fmt.Errorf("turning the entropy into words: %w", err) + } + + return child, nil +} diff --git a/internal/childmnemonic/childmnemonic_test.go b/internal/childmnemonic/childmnemonic_test.go new file mode 100644 index 0000000..ccd8990 --- /dev/null +++ b/internal/childmnemonic/childmnemonic_test.go @@ -0,0 +1,117 @@ +package childmnemonic_test + +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" +) + +// The lengths the tool offers, and one it does not. +const ( + twelve = 12 + eighteen = 18 + twentyFour = 24 + fifteen = 15 +) + +// specificationKey is the master key the BIP-85 specification gives +// every one of its test vectors for. +const specificationKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBq" + + "sx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" + +// The three English child mnemonics at key index 0 that the BIP-85 +// specification gives for that master key. +const ( + twelveWords = "girl mad pet galaxy egg matter matrix prison refuse " + + "sense ordinary nose" + + eighteenWords = "near account window bike charge season chef number " + + "sketch tomorrow excuse sniff circle vital hockey outdoor " + + "supply token" + + twentyFourWords = "puppy ocean match cereal symbol another shed " + + "magic wrap hammer bulb intact gadget divorce twin tonight " + + "reason outdoor destroy simple truth cigar social volcano" +) + +// example returns the mnemonic every BIP-39 document uses to show its +// test vectors: eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +func TestTheSpecificationTestVectors(t *testing.T) { + t.Parallel() + + require.Equal(t, twelveWords, fromSpecificationKey(t, twelve)) + require.Equal(t, eighteenWords, fromSpecificationKey(t, eighteen)) + require.Equal(t, twentyFourWords, fromSpecificationKey(t, twentyFour)) +} + +func TestTheLongestChildMnemonicPassesItsOwnChecksum(t *testing.T) { + t.Parallel() + + child := fromSpecificationKey(t, twentyFour) + + require.Len(t, strings.Fields(child), twentyFour) + require.True(t, bip39.IsMnemonicValid(child)) +} + +func TestEachKeyIndexGivesItsOwnChildMnemonic(t *testing.T) { + t.Parallel() + + master, err := derive.Master(example()) + require.NoError(t, err) + + first, err := childmnemonic.Derive(master, twelve, 0) + require.NoError(t, err) + require.True(t, bip39.IsMnemonicValid(first)) + + second, err := childmnemonic.Derive(master, twelve, 1) + require.NoError(t, err) + require.True(t, bip39.IsMnemonicValid(second)) + + require.NotEqual(t, first, second) +} + +func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) { + t.Parallel() + + master, err := hdkeychain.NewKeyFromString(specificationKey) + require.NoError(t, err) + + _, err = childmnemonic.Derive(master, fifteen, 0) + require.ErrorIs(t, err, childmnemonic.ErrWordCount) +} + +func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { + t.Parallel() + + master, err := hdkeychain.NewKeyFromString(specificationKey) + require.NoError(t, err) + + _, err = childmnemonic.Derive(master, twelve, derive.MaxIndex+1) + require.ErrorIs(t, err, derive.ErrIndexTooLarge) + + _, err = childmnemonic.Derive(master, twelve, derive.MaxIndex) + require.NoError(t, err) +} + +// fromSpecificationKey derives the child mnemonic of that length at key +// index 0 from the master key the specification gives. +func fromSpecificationKey(t *testing.T, words uint32) string { + t.Helper() + + master, err := hdkeychain.NewKeyFromString(specificationKey) + require.NoError(t, err) + + child, err := childmnemonic.Derive(master, words, 0) + require.NoError(t, err) + + return child +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b901700..7e95c4b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "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" @@ -29,7 +30,7 @@ func Root() *cobra.Command { } options.Add(root) - root.AddCommand(ssh.Command()) + root.AddCommand(ssh.Command(), mnemonic.Command()) return root } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 75b3df8..fa11a40 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,10 +5,12 @@ 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" ) @@ -20,6 +22,12 @@ const ( "0I4FKs+eVUulTPHfk9VtXw1tMF" ) +// The two child mnemonic lengths the tests ask for. +const ( + twelve = 12 + twentyFour = 24 +) + // example returns the mnemonic the README gives its test vectors for: // eleven abandons and about. func example() string { @@ -81,6 +89,28 @@ func TestTheMnemonicCommandIsUsed(t *testing.T) { require.Equal(t, vectorZero+" keyfunc/ssh/0", line) } +func TestAChildMnemonicIsPrinted(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + short := strings.Fields(run(t, "mnemonic")) + require.Len(t, short, twelve) + require.True(t, bip39.IsMnemonicValid(strings.Join(short, " "))) + + long := strings.Fields(run(t, "mnemonic", "--words", "24")) + require.Len(t, long, twentyFour) + + next := strings.Fields(run(t, "mnemonic", "-n", "1")) + require.NotEqual(t, short, next) +} + +func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + out, err := execute(t, "mnemonic", "--words", "15") + require.ErrorIs(t, err, childmnemonic.ErrWordCount) + require.Empty(t, out) +} + // run executes the tool with the given arguments and returns what it // wrote to standard output. func run(t *testing.T, args ...string) string { diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go new file mode 100644 index 0000000..e157e18 --- /dev/null +++ b/internal/cli/mnemonic/mnemonic.go @@ -0,0 +1,65 @@ +// Package mnemonic is the command that prints a child mnemonic. +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" +) + +// Command returns the mnemonic command. +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "mnemonic", + Short: "print a child mnemonic derived from the main one", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + child, err := derived(cmd) + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), child) + if err != nil { + return fmt.Errorf("writing the mnemonic: %w", err) + } + + return nil + }, + } + + cmd.Flags().Uint32( + "words", childmnemonic.DefaultWords, + "how long the child mnemonic is: 12, 18 or 24 words", + ) + + return cmd +} + +// derived returns the child mnemonic this run asks for. +func derived(cmd *cobra.Command) (string, error) { + index, err := options.Index(cmd) + if err != nil { + return "", err + } + + words, err := cmd.Flags().GetUint32("words") + if err != nil { + return "", fmt.Errorf("reading the word count: %w", err) + } + + parent, err := options.Mnemonic(cmd) + if err != nil { + return "", err + } + + master, err := derive.Master(parent) + if err != nil { + return "", err + } + + return childmnemonic.Derive(master, words, index) +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go index fe9176c..bd95906 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -35,24 +35,47 @@ func Path(application, index uint32) string { return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index) } +// CheckIndex refuses a key index above MaxIndex, so that every key +// type turns such an index down before deriving anything. +func CheckIndex(index uint32) error { + if index > MaxIndex { + return fmt.Errorf( + "%w: %d is above %d", + ErrIndexTooLarge, index, MaxIndex, + ) + } + + return nil +} + +// Master returns the BIP-32 master key a mnemonic stands for: the +// mnemonic becomes a seed with an empty passphrase, and the seed +// becomes the key. +func Master(words string) (*hdkeychain.ExtendedKey, error) { + seed := bip39.NewSeed(words, "") + + master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + if err != nil { + return nil, fmt.Errorf("making the master key: %w", err) + } + + return master, nil +} + // Bytes returns the bytes for an application number and a key index. // The mnemonic becomes a seed with an empty passphrase, the seed // becomes a master key, the master key gives BIP-85 entropy at the // path, and the entropy seeds the generator the bytes are read from. // An index above MaxIndex is refused before any of that happens. func Bytes(words string, application, index uint32) ([]byte, error) { - if index > MaxIndex { - return nil, fmt.Errorf( - "%w: %d is above %d", - ErrIndexTooLarge, index, MaxIndex, - ) + err := CheckIndex(index) + if err != nil { + return nil, err } - seed := bip39.NewSeed(words, "") - - master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + master, err := Master(words) if err != nil { - return nil, fmt.Errorf("making the master key: %w", err) + return nil, err } entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index)) -- 2.49.1 From 5bbeec86d6cb7f8fc1e6c1567b1da1c58e0f136e Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 7 Sep 2026 18:35:11 +0200 Subject: [PATCH 3/4] The age commands: pub, priv, encrypt and decrypt (closes #3) keyfunc age derives an age identity at the generic path the way secret's agehd does, prints the recipient or the identity, and encrypts to or decrypts with it, the derived recipient always among encrypt's recipients. Two review rounds; the second passed with no findings, the clamping step now pinned by a fixed identity test. Model: opus-5 (implementation and review); fable-5-1 (landing) --- go.mod | 1 + go.sum | 4 + internal/agekey/agekey.go | 203 +++++++++++++++++++++++++ internal/agekey/agekey_test.go | 169 +++++++++++++++++++++ internal/cli/age/age.go | 263 +++++++++++++++++++++++++++++++++ internal/cli/age_test.go | 102 +++++++++++++ internal/cli/cli.go | 3 +- 7 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 internal/agekey/agekey.go create mode 100644 internal/agekey/agekey_test.go create mode 100644 internal/cli/age/age.go create mode 100644 internal/cli/age_test.go diff --git a/go.mod b/go.mod index 7934093..4a2f2ce 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.eeqj.de/sneak/keyfunc go 1.26 require ( + filippo.io/age v1.2.1 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 diff --git a/go.sum b/go.sum index 7bc30ce..4915728 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +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= 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= diff --git a/internal/agekey/agekey.go b/internal/agekey/agekey.go new file mode 100644 index 0000000..7f11687 --- /dev/null +++ b/internal/agekey/agekey.go @@ -0,0 +1,203 @@ +// Package agekey turns derived bytes into an age identity and uses +// that identity to encrypt and decrypt. +package agekey + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" + + "filippo.io/age" + "filippo.io/age/armor" + "github.com/btcsuite/btcd/btcutil/bech32" +) + +const ( + // Application is the number this key type occupies in the + // derivation path. It spells AGE the way BIP-85 spells RSA, as + // the ASCII codes of the letters written out. + Application = 657169 + + // keySize is how long an X25519 secret key is. + keySize = 32 + + // humanPart is what age puts in front of a secret key when it + // writes one down. + humanPart = "age-secret-key-" +) + +// ErrSize is returned when the derived bytes are not the length an +// X25519 secret key has to be. +var ErrSize = errors.New("an age identity needs 32 derived bytes") + +// ErrNotRecipient is returned when the file was encrypted to someone +// else, so this key cannot open it. +var ErrNotRecipient = errors.New( + "this mnemonic and index are not a recipient of the file", +) + +// Key is one age identity. +type Key struct { + identity *age.X25519Identity +} + +// New makes the identity whose X25519 secret key is the derived bytes, +// clamped the way that curve requires. +func New(derived []byte) (*Key, error) { + if len(derived) != keySize { + return nil, fmt.Errorf("%w, got %d", ErrSize, len(derived)) + } + + scalar := make([]byte, keySize) + copy(scalar, derived) + clamp(scalar) + + // age offers no way to make an identity out of bytes, so the + // scalar goes in the way age writes a secret key down. + written, err := bech32.EncodeFromBase256(humanPart, scalar) + if err != nil { + return nil, fmt.Errorf("writing the secret key: %w", err) + } + + identity, err := age.ParseX25519Identity(strings.ToUpper(written)) + if err != nil { + return nil, fmt.Errorf("reading the secret key back: %w", err) + } + + return &Key{identity: identity}, nil +} + +// clamp makes the scalar one the curve accepts: the lowest three bits +// off, the highest bit off and the one below it on, as RFC 7748 says. +func clamp(scalar []byte) { + const ( + lowestThreeOff = 0b1111_1000 + highestOff = 0b0111_1111 + secondHighestOn = 0b0100_0000 + ) + + scalar[0] &= lowestThreeOff + scalar[len(scalar)-1] &= highestOff + scalar[len(scalar)-1] |= secondHighestOn +} + +// Recipient returns the public key, the age1... line. +func (k *Key) Recipient() string { + return k.identity.Recipient().String() +} + +// Identity returns the secret key, the AGE-SECRET-KEY-1... line. +func (k *Key) Identity() string { + return k.identity.String() +} + +// Encrypt copies src to dst, encrypted to this key and to every extra +// recipient named, so the same mnemonic can always read it back again. +// Armored output is the text form age also reads. +func (k *Key) Encrypt( + dst io.Writer, src io.Reader, to []string, armored bool, +) error { + all, err := recipients(k.identity.Recipient(), to) + if err != nil { + return err + } + + out := dst + + var text io.WriteCloser + + if armored { + text = armor.NewWriter(dst) + out = text + } + + err = encrypt(out, src, all) + if err != nil { + return err + } + + if text == nil { + return nil + } + + err = text.Close() + if err != nil { + return fmt.Errorf("finishing the text form: %w", err) + } + + return nil +} + +// recipients returns the key's own recipient followed by the ones +// named on the command line, so what the key encrypts it can read. +func recipients(mine age.Recipient, to []string) ([]age.Recipient, error) { + list := []age.Recipient{mine} + + for _, name := range to { + parsed, err := age.ParseX25519Recipient(name) + if err != nil { + return nil, fmt.Errorf("reading recipient %q: %w", name, err) + } + + list = append(list, parsed) + } + + return list, nil +} + +// encrypt writes src into dst for the recipients. +func encrypt(dst io.Writer, src io.Reader, to []age.Recipient) error { + sealed, err := age.Encrypt(dst, to...) + if err != nil { + return fmt.Errorf("starting the encryption: %w", err) + } + + _, err = io.Copy(sealed, src) + if err != nil { + return fmt.Errorf("encrypting: %w", err) + } + + err = sealed.Close() + if err != nil { + return fmt.Errorf("finishing the encryption: %w", err) + } + + return nil +} + +// Decrypt copies src to dst, decrypted with this key. The text form is +// recognised by the line it starts with, so it needs no flag. +func (k *Key) Decrypt(dst io.Writer, src io.Reader) error { + plain, err := age.Decrypt(unarmored(src), k.identity) + + noMatch := &age.NoIdentityMatchError{} + if errors.As(err, &noMatch) { + return ErrNotRecipient + } + + if err != nil { + return fmt.Errorf("decrypting: %w", err) + } + + _, err = io.Copy(dst, plain) + if err != nil { + return fmt.Errorf("reading the decrypted file: %w", err) + } + + return nil +} + +// unarmored strips the text form when the input begins with the line +// that starts one, and leaves binary input alone. +func unarmored(src io.Reader) io.Reader { + buffered := bufio.NewReader(src) + + start, err := buffered.Peek(len(armor.Header)) + if err == nil && string(start) == armor.Header { + return armor.NewReader(buffered) + } + + return buffered +} diff --git a/internal/agekey/agekey_test.go b/internal/agekey/agekey_test.go new file mode 100644 index 0000000..376e3ab --- /dev/null +++ b/internal/agekey/agekey_test.go @@ -0,0 +1,169 @@ +package agekey_test + +import ( + "bytes" + "strings" + "testing" + + "git.eeqj.de/sneak/keyfunc/internal/agekey" + "git.eeqj.de/sneak/keyfunc/internal/derive" + "github.com/stretchr/testify/require" +) + +// The recipients the example mnemonic produces at the first two +// indexes, and the secret key behind the first of them. They are what +// makes the derivation reproducible: if the recipients change, every +// file anyone encrypted becomes unreadable, and if the secret key +// changes, the key is no longer the one other tools derive from the +// same mnemonic. +const ( + recipientZero = "age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysew" + + "lj68kmx5n67e3nsg2mftq" + recipientOne = "age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfg" + + "qm7hmakqhvgyy4q5p3w7j" + identityZero = "AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD" + + "7527T70JDHGDJ7AMNF3XT44S00JFU5" +) + +// example returns the mnemonic every BIP-39 document uses to show its +// test vectors: eleven abandons and about. +func example() string { + return strings.Repeat("abandon ", 11) + "about" +} + +func TestTooFewBytesAreRefused(t *testing.T) { + t.Parallel() + + _, err := agekey.New([]byte("short")) + require.ErrorIs(t, err, agekey.ErrSize) +} + +func TestTheSameMnemonicAlwaysGivesTheSameKey(t *testing.T) { + t.Parallel() + + require.Equal(t, recipientZero, forIndex(t, 0).Recipient()) + require.Equal(t, recipientOne, forIndex(t, 1).Recipient()) +} + +func TestTheSameMnemonicAlwaysGivesTheSameSecretKey(t *testing.T) { + t.Parallel() + + require.Equal(t, identityZero, forIndex(t, 0).Identity()) +} + +func TestWhatWasEncryptedComesBack(t *testing.T) { + t.Parallel() + + // Every byte value, so nothing assumes the input is text, and + // then text, which is what most of it will be. + payloads := map[string][]byte{ + "every byte": everyByte(), + "text": []byte("the quick brown fox\nand a second line\n"), + } + + forms := map[string]bool{"binary": false, "armored": true} + + for name, payload := range payloads { + for form, armored := range forms { + t.Run(name+" "+form, func(t *testing.T) { + t.Parallel() + + key := forIndex(t, 0) + + var sealed, opened bytes.Buffer + + err := key.Encrypt( + &sealed, bytes.NewReader(payload), nil, armored, + ) + require.NoError(t, err) + + err = key.Decrypt(&opened, &sealed) + require.NoError(t, err) + require.Equal(t, payload, opened.Bytes()) + }) + } + } +} + +func TestTheArmoredFormIsText(t *testing.T) { + t.Parallel() + + var sealed bytes.Buffer + + err := forIndex(t, 0).Encrypt( + &sealed, strings.NewReader("hello"), nil, true, + ) + require.NoError(t, err) + require.True(t, strings.HasPrefix( + sealed.String(), "-----BEGIN AGE ENCRYPTED FILE-----", + )) +} + +func TestAFileForSomebodyElseIsRefused(t *testing.T) { + t.Parallel() + + var sealed, opened bytes.Buffer + + err := forIndex(t, 1).Encrypt( + &sealed, strings.NewReader("hello"), nil, false, + ) + require.NoError(t, err) + + err = forIndex(t, 0).Decrypt(&opened, &sealed) + require.ErrorIs(t, err, agekey.ErrNotRecipient) + require.Empty(t, opened.Bytes()) +} + +func TestAnExtraRecipientCanReadItTooAndSoCanTheDerivedOne(t *testing.T) { + t.Parallel() + + mine, theirs := forIndex(t, 0), forIndex(t, 1) + + var sealed bytes.Buffer + + err := mine.Encrypt( + &sealed, strings.NewReader("hello"), + []string{theirs.Recipient()}, false, + ) + require.NoError(t, err) + + for _, key := range []*agekey.Key{mine, theirs} { + var opened bytes.Buffer + + require.NoError(t, key.Decrypt(&opened, bytes.NewReader(sealed.Bytes()))) + require.Equal(t, "hello", opened.String()) + } +} + +func TestARecipientThatIsNotOneIsRefused(t *testing.T) { + t.Parallel() + + err := forIndex(t, 0).Encrypt( + &bytes.Buffer{}, strings.NewReader("hello"), + []string{"not a recipient"}, false, + ) + require.Error(t, err) +} + +// everyByte returns a payload holding all 256 byte values. +func everyByte() []byte { + out := make([]byte, 256) + for i := range out { + out[i] = byte(i) + } + + return out +} + +// forIndex derives the key for one index. +func forIndex(t *testing.T, index uint32) *agekey.Key { + t.Helper() + + material, err := derive.Bytes(example(), agekey.Application, index) + require.NoError(t, err) + + key, err := agekey.New(material) + require.NoError(t, err) + + return key +} diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go new file mode 100644 index 0000000..4de2b60 --- /dev/null +++ b/internal/cli/age/age.go @@ -0,0 +1,263 @@ +// Package age groups the commands that derive age identities and +// encrypt and decrypt with them. +package age + +import ( + "fmt" + "io" + "os" + "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" +) + +// Command returns the age command and everything under it. +func Command() *cobra.Command { + group := &cobra.Command{ + Use: "age", + Short: "derive age identities and encrypt and decrypt with them", + } + + group.AddCommand(public(), private(), encrypt(), decrypt()) + + return group +} + +// public returns the command that prints the recipient. +func public() *cobra.Command { + return &cobra.Command{ + Use: "pub", + Short: "print the recipient, the age1... public key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + key, err := derived(cmd) + if err != nil { + return err + } + + return write(cmd, key.Recipient()) + }, + } +} + +// private returns the command that prints the identity. +func private() *cobra.Command { + return &cobra.Command{ + Use: "priv", + Short: "print the identity, the AGE-SECRET-KEY-1... line", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + key, err := derived(cmd) + if err != nil { + return err + } + + return write(cmd, key.Identity()) + }, + } +} + +// encrypt returns the command that encrypts a file or standard input. +func encrypt() *cobra.Command { + cmd := &cobra.Command{ + Use: "encrypt [file]", + Short: "encrypt to the derived recipient and any others given", + Args: cobra.MaximumNArgs(1), + RunE: runEncrypt, + } + + cmd.Flags().StringArray( + "to", nil, + "another recipient to encrypt to, as well as the derived one", + ) + cmd.Flags().Bool( + "armor", false, + "write the text form instead of the binary one", + ) + addOutput(cmd) + + return cmd +} + +// decrypt returns the command that decrypts a file or standard input. +func decrypt() *cobra.Command { + cmd := &cobra.Command{ + Use: "decrypt [file]", + Short: "decrypt with the derived identity", + Args: cobra.MaximumNArgs(1), + RunE: runDecrypt, + } + + addOutput(cmd) + + return cmd +} + +// runEncrypt encrypts to the derived recipient and any others given. +func runEncrypt(cmd *cobra.Command, args []string) error { + key, err := derived(cmd) + if err != nil { + return err + } + + to, err := cmd.Flags().GetStringArray("to") + if err != nil { + return fmt.Errorf("reading the recipients: %w", err) + } + + armored, err := cmd.Flags().GetBool("armor") + if err != nil { + return fmt.Errorf("reading the armor flag: %w", err) + } + + return through(cmd, args, func(dst io.Writer, src io.Reader) error { + return key.Encrypt(dst, src, to, armored) + }) +} + +// runDecrypt decrypts with the derived identity. +func runDecrypt(cmd *cobra.Command, args []string) error { + key, err := derived(cmd) + if err != nil { + return err + } + + 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. +func through( + cmd *cobra.Command, args []string, + work func(io.Writer, io.Reader) error, +) error { + src, closeSrc, err := input(cmd, args) + if err != nil { + return err + } + + defer closeSrc() + + dst, done, err := output(cmd) + if err != nil { + return err + } + + err = work(dst, src) + + return done(err) +} + +// input returns what to read from: the named file, or the command's +// own input when no file is named. The second result closes a file +// that was opened and does nothing otherwise. +func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { + if len(args) == 0 { + return cmd.InOrStdin(), func() {}, nil + } + + file, err := os.Open(args[0]) + if err != nil { + return nil, nil, fmt.Errorf("opening %s: %w", args[0], err) + } + + 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") + if err != nil { + return nil, nil, fmt.Errorf("reading the output file: %w", err) + } + + if name == "" { + return cmd.OutOrStdout(), func(failed error) error { + return failed + }, nil + } + + // 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 file, func(failed error) error { + return finish(file, name, failed) + }, nil +} + +// finish closes the new file and puts it in the named file's place, or +// throws it away when the work failed. It returns the error the caller +// should report. +func finish(file *os.File, name string, failed error) error { + closeErr := file.Close() + + if failed != nil || closeErr != nil { + _ = os.Remove(file.Name()) + + if failed != nil { + return failed + } + + return fmt.Errorf("finishing %s: %w", name, closeErr) + } + + err := os.Rename(file.Name(), name) + if err != nil { + _ = os.Remove(file.Name()) + + return fmt.Errorf("putting %s in place: %w", name, err) + } + + return nil +} + +// addOutput gives a command its output file flag. +func addOutput(cmd *cobra.Command) { + cmd.Flags().StringP( + "output", "o", "", + "write to this file instead of standard output", + ) +} + +// write sends one line to wherever the command's output goes. +func write(cmd *cobra.Command, line string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), line) + if err != nil { + return fmt.Errorf("writing the key: %w", err) + } + + return nil +} + +// derived returns the age key for this run. +func derived(cmd *cobra.Command) (*agekey.Key, error) { + index, err := options.Index(cmd) + if err != nil { + return nil, err + } + + words, err := options.Mnemonic(cmd) + if err != nil { + return nil, err + } + + material, err := derive.Bytes(words, agekey.Application, index) + if err != nil { + return nil, err + } + + return agekey.New(material) +} diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go new file mode 100644 index 0000000..3d452b9 --- /dev/null +++ b/internal/cli/age_test.go @@ -0,0 +1,102 @@ +package cli_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.eeqj.de/sneak/keyfunc/internal/agekey" + "git.eeqj.de/sneak/keyfunc/internal/mnemonic" + "github.com/stretchr/testify/require" +) + +func TestTheAgeCommandsPrintTheKey(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + recipient := strings.TrimSpace(run(t, "age", "pub")) + require.True(t, strings.HasPrefix(recipient, "age1")) + + identity := strings.TrimSpace(run(t, "age", "priv")) + require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1")) +} + +func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + sealed := filepath.Join(t.TempDir(), "notes.age") + + run(t, "age", "encrypt", "-o", sealed, plain) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) +} + +func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + + armored := run(t, "age", "encrypt", "--armor", plain) + require.True(t, strings.HasPrefix( + armored, "-----BEGIN AGE ENCRYPTED FILE-----", + )) + + sealed := written(t, "notes.age", armored) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) +} + +func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7")) + plain := written(t, "notes.txt", "the secret\n") + sealed := filepath.Join(t.TempDir(), "notes.age") + + run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain) + + require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) + require.Equal(t, + "the secret\n", run(t, "age", "decrypt", "-n", "7", sealed), + ) +} + +func TestAFileForAnotherKeyIsRefused(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + sealed := filepath.Join(t.TempDir(), "notes.age") + + run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain) + + _, err := execute(t, "age", "decrypt", sealed) + require.ErrorIs(t, err, agekey.ErrNotRecipient) +} + +func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + sealed := filepath.Join(t.TempDir(), "notes.age") + existing := written(t, "notes.out", "what was already there\n") + + run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain) + + _, err := execute(t, "age", "decrypt", "-o", existing, sealed) + require.ErrorIs(t, err, agekey.ErrNotRecipient) + + //nolint:gosec // the test made this path itself + kept, err := os.ReadFile(existing) + require.NoError(t, err) + require.Equal(t, "what was already there\n", string(kept)) +} + +// 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 { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + + return path +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7e95c4b..e06d916 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "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" @@ -30,7 +31,7 @@ func Root() *cobra.Command { } options.Add(root) - root.AddCommand(ssh.Command(), mnemonic.Command()) + root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command()) return root } -- 2.49.1 From b9c8631788ded552ec414011ae5a0d443182b0d0 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 7 Sep 2026 18:49:42 +0200 Subject: [PATCH 4/4] The ssh install and ssh to commands (closes #2) keyfunc ssh install appends the public line on a host through the system ssh, only when absent, feeding the line on standard input; keyfunc ssh to serves the derived key from an in-process agent on a private socket and runs the system ssh with it, the private key never on disk. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing) --- internal/cli/cli.go | 21 ++- internal/cli/ssh/install.go | 96 +++++++++++++ internal/cli/ssh/ssh.go | 2 +- internal/cli/ssh/to.go | 95 ++++++++++++ internal/cli/ssh_test.go | 255 +++++++++++++++++++++++++++++++++ internal/sshkey/agent.go | 86 +++++++++++ internal/sshkey/sshkey_test.go | 70 ++++++++- 7 files changed, 613 insertions(+), 12 deletions(-) create mode 100644 internal/cli/ssh/install.go create mode 100644 internal/cli/ssh/to.go create mode 100644 internal/cli/ssh_test.go create mode 100644 internal/sshkey/agent.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e06d916..c6d63d0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( + "errors" "fmt" "os" @@ -37,14 +38,22 @@ func Root() *cobra.Command { } // Main runs the tool and returns the status the process should exit -// with. +// 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. func Main() int { err := Root().Execute() - if err != nil { - fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error()) - - return 1 + if err == nil { + return 0 } - return 0 + var passed ssh.StatusError + if errors.As(err, &passed) { + return passed.Status + } + + fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error()) + + return 1 } diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go new file mode 100644 index 0000000..8133261 --- /dev/null +++ b/internal/cli/ssh/install.go @@ -0,0 +1,96 @@ +package ssh + +import ( + "fmt" + "os/exec" + "slices" + "strings" + + "github.com/spf13/cobra" +) + +// 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 +` + +// 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...]", + 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), + RunE: func(cmd *cobra.Command, args []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + line, err := key.Line(comment) + if err != nil { + return err + } + + return send(cmd, args[0], args[1:], line) + }, + } + + addComment(cmd) + + 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() + if err != nil { + return fmt.Errorf("running ssh: %w", err) + } + + return nil +} diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index f707214..b15a08b 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -17,7 +17,7 @@ func Command() *cobra.Command { Short: "derive ed25519 SSH keys", } - group.AddCommand(public(), private()) + group.AddCommand(public(), private(), install(), to()) return group } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go new file mode 100644 index 0000000..8dc5149 --- /dev/null +++ b/internal/cli/ssh/to.go @@ -0,0 +1,95 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "slices" + + "github.com/spf13/cobra" +) + +// StatusError says the tool should end with the status ssh ended with. +// Only "ssh to" gives one back; every other error ends the tool with +// status 1. +type StatusError struct { + Status int +} + +// Error says which status ssh ended with. +func (e StatusError) Error() string { + return fmt.Sprintf("ssh exited with status %d", e.Status) +} + +// to returns the command that runs ssh with the derived key held by an +// agent of the tool's own. +func to() *cobra.Command { + cmd := &cobra.Command{ + Use: "to [ssh arguments...]", + Short: "run ssh with the derived key served from its own agent", + Long: "Serves the derived key from an SSH agent that runs " + + "inside the tool and points the system ssh at it. The host " + + "and everything after it are given to ssh unchanged, the " + + "tool ends with the status ssh ended with, and the key is " + + "never written to disk.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + served, err := key.Serve(cmd.Context(), comment) + if err != nil { + return err + } + + defer served.Stop() + + argv := slices.Concat([]string{ + "-o", "IdentityAgent=" + served.Socket(), + }, args) + + return connect(cmd.Context(), argv) + }, + } + + // Everything from the host onwards belongs to ssh, so flag + // reading stops at the first argument that is not a flag. + cmd.Flags().SetInterspersed(false) + + addComment(cmd) + + return cmd +} + +// connect runs ssh on the terminal the tool was given and turns the +// status it ended with into the status the tool ends with. +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.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + + err := command.Run() + if err == nil { + return nil + } + + var ended *exec.ExitError + if errors.As(err, &ended) { + status := ended.ExitCode() + if status < 0 { + // A signal ended ssh, and a signal has no status of its + // own to pass on. + status = 1 + } + + return StatusError{Status: status} + } + + return fmt.Errorf("running ssh: %w", err) +} diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go new file mode 100644 index 0000000..6b787fc --- /dev/null +++ b/internal/cli/ssh_test.go @@ -0,0 +1,255 @@ +package cli_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "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" +) + +// 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. +const ( + directoryMode = 0o700 + fileMode = 0o600 + standInMode = 0o755 +) + +// failingStatus is the status the stand-in ssh ends with when a test +// wants to see a status handed on. +const failingStatus = 7 + +// The host, and where on it the key ends up. +const ( + host = "someone@example.com" + keptUnder = ".ssh" + 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. +const installer = ` +while [ $# -gt 1 ]; do + printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" + shift +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. +const caller = ` +for argument in "$@"; do + printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" +done +socket=${2#IdentityAgent=} +if [ -S "$socket" ]; then + printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET" +fi +exit "$KEYFUNC_TEST_STATUS" +` + +// pretended is where a stand-in ssh 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 string + // command holds what ssh was told to run on the host. + command string +} + +func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + require.Equal(t, "added\n", run(t, "ssh", "install", host)) + + directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) + require.NoError(t, err) + require.Equal(t, + os.FileMode(directoryMode), directory.Mode().Perm(), + ) + + path := filepath.Join(pretend.home, keptUnder, keptIn) + + file, err := os.Stat(path) + 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, "already present\n", run(t, "ssh", "install", host)) + require.Equal(t, added, read(t, path)) +} + +func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + already := "ssh-ed25519 AAAAsomebodyelse somebody@else" + + 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), + ) +} + +func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + run(t, "ssh", "install", host) + + require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") + require.NotContains(t, read(t, pretend.command), "ssh-ed25519") +} + +func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + run(t, "ssh", "install", host, "--", "-p", "2222") + + require.Equal(t, + []string{"-p", "2222", host}, + recorded(t, pretend.arguments), + ) +} + +func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + arguments, noted := pretendCall(t) + + _, err := execute(t, "ssh", "to", host, "uptime") + + var passed ssh.StatusError + + require.ErrorAs(t, err, &passed) + require.Equal(t, failingStatus, passed.Status) + + given := recorded(t, arguments) + require.Equal(t, "-o", given[0]) + require.Equal(t, []string{host, "uptime"}, given[2:]) + + // The stand-in wrote the path down only because there really was + // a socket there while it ran. + socket := strings.TrimSpace(read(t, noted)) + require.Equal(t, "IdentityAgent="+socket, given[1]) + require.NoDirExists(t, filepath.Dir(socket)) +} + +func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretendCall(t) + + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} + + require.Equal(t, failingStatus, cli.Main()) +} + +// pretendHost puts the install stand-in on the path and gives back the +// places it writes to. +func pretendHost(t *testing.T) pretended { + t.Helper() + + pretend := pretended{ + home: t.TempDir(), + arguments: filepath.Join(t.TempDir(), "arguments"), + command: filepath.Join(t.TempDir(), "command"), + } + + t.Setenv("KEYFUNC_TEST_HOME", pretend.home) + t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) + t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) + standIn(t, installer) + + return pretend +} + +// 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. +func pretendCall(t *testing.T) (string, string) { + t.Helper() + + arguments := filepath.Join(t.TempDir(), "arguments") + noted := filepath.Join(t.TempDir(), "socket") + + t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) + t.Setenv("KEYFUNC_TEST_SOCKET", noted) + t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) + standIn(t, 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) { + t.Helper() + + directory := t.TempDir() + + err := os.WriteFile( + filepath.Join(directory, "ssh"), + []byte("#!/bin/sh\n"+body), standInMode, + ) + require.NoError(t, err) + + t.Setenv("PATH", + directory+string(os.PathListSeparator)+os.Getenv("PATH"), + ) +} + +// read returns what is in a file. +func read(t *testing.T, path string) string { + t.Helper() + + //nolint:gosec // the path is a temporary file of the test's own + content, err := os.ReadFile(path) + require.NoError(t, err) + + return string(content) +} + +// recorded returns the arguments a stand-in wrote down, one per line. +func recorded(t *testing.T, path string) []string { + t.Helper() + + return strings.Split(strings.TrimSuffix(read(t, path), "\n"), "\n") +} diff --git a/internal/sshkey/agent.go b/internal/sshkey/agent.go new file mode 100644 index 0000000..d917e54 --- /dev/null +++ b/internal/sshkey/agent.go @@ -0,0 +1,86 @@ +package sshkey + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh/agent" +) + +// Agent is an SSH agent that holds one key and serves it on a unix +// socket. The socket sits in a directory of its own that only its +// owner may enter, and the key stays in memory: nothing is written to +// disk. +type Agent struct { + socket string + listener net.Listener +} + +// Serve starts an agent holding this key under the given comment. +// Stop takes it down again. +func (k *Key) Serve(ctx context.Context, comment string) (*Agent, error) { + keyring := agent.NewKeyring() + + err := keyring.Add(agent.AddedKey{ + PrivateKey: k.private, + Comment: comment, + }) + if err != nil { + return nil, fmt.Errorf("giving the key to the agent: %w", err) + } + + // A temporary directory is made enterable by its owner alone, + // which is the protection the socket inside it has. + directory, err := os.MkdirTemp("", "keyfunc-agent-") + if err != nil { + return nil, fmt.Errorf("making the agent directory: %w", err) + } + + socket := filepath.Join(directory, "socket") + + var listen net.ListenConfig + + listener, err := listen.Listen(ctx, "unix", socket) + if err != nil { + _ = os.RemoveAll(directory) + + return nil, fmt.Errorf("listening on the agent socket: %w", err) + } + + served := &Agent{socket: socket, listener: listener} + + go served.accept(keyring) + + return served, nil +} + +// Socket is the path to point ssh at. +func (a *Agent) Socket() string { + return a.socket +} + +// Stop takes the agent down and removes the socket and the directory +// it is in. +func (a *Agent) Stop() { + _ = a.listener.Close() + _ = os.RemoveAll(filepath.Dir(a.socket)) +} + +// accept answers connections until Stop closes the listener. +func (a *Agent) accept(keyring agent.Agent) { + for { + connection, err := a.listener.Accept() + if err != nil { + return + } + + go func() { + defer func() { _ = connection.Close() }() + + _ = agent.ServeAgent(keyring, connection) + }() + } +} diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go index 02a40c9..2ab8cb6 100644 --- a/internal/sshkey/sshkey_test.go +++ b/internal/sshkey/sshkey_test.go @@ -1,6 +1,9 @@ package sshkey_test import ( + "net" + "os" + "path/filepath" "strings" "testing" @@ -8,8 +11,16 @@ import ( "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" ) +// agentDirectoryMode is what the directory holding the agent socket +// has to be: nobody but its owner may enter it. +const agentDirectoryMode = 0o700 + +// exampleIndex is the key index every test here derives at. +const exampleIndex = 0 + // example returns the mnemonic every BIP-39 document uses to show its // test vectors: eleven abandons and about. func example() string { @@ -26,7 +37,7 @@ func TestTooFewBytesAreRefused(t *testing.T) { func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) { t.Parallel() - key := forIndex(t, 0) + key := exampleKey(t) line, err := key.Line("hello") require.NoError(t, err) @@ -37,7 +48,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) { func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) { t.Parallel() - key := forIndex(t, 0) + key := exampleKey(t) line, err := key.Line("") require.NoError(t, err) @@ -54,11 +65,60 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) { require.Equal(t, line, back) } -// forIndex derives the key for one index. -func forIndex(t *testing.T, index uint32) *sshkey.Key { +func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) { + t.Parallel() + + key := exampleKey(t) + + served, err := key.Serve(t.Context(), "a comment") + require.NoError(t, err) + t.Cleanup(served.Stop) + + directory, err := os.Stat(filepath.Dir(served.Socket())) + require.NoError(t, err) + require.Equal(t, + os.FileMode(agentDirectoryMode), directory.Mode().Perm(), + ) + + var dialer net.Dialer + + connection, err := dialer.DialContext(t.Context(), "unix", served.Socket()) + require.NoError(t, err) + + defer func() { _ = connection.Close() }() + + held, err := agent.NewClient(connection).List() + require.NoError(t, err) + require.Len(t, held, 1) + + line, err := key.Line("a comment") + require.NoError(t, err) + require.Equal(t, line, held[0].String()) +} + +func TestStoppingTheAgentLeavesNothingBehind(t *testing.T) { + t.Parallel() + + served, err := exampleKey(t).Serve(t.Context(), "a comment") + require.NoError(t, err) + + directory := filepath.Dir(served.Socket()) + require.DirExists(t, directory) + + served.Stop() + require.NoDirExists(t, directory) + + var dialer net.Dialer + + _, err = dialer.DialContext(t.Context(), "unix", served.Socket()) + require.Error(t, err) +} + +// exampleKey derives the key the example mnemonic gives. +func exampleKey(t *testing.T) *sshkey.Key { t.Helper() - material, err := derive.Bytes(example(), sshkey.Application, index) + material, err := derive.Bytes(example(), sshkey.Application, exampleIndex) require.NoError(t, err) key, err := sshkey.New(material) -- 2.49.1