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..4a2f2ce --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +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 + 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..4915728 --- /dev/null +++ b/go.sum @@ -0,0 +1,136 @@ +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= +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/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/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/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 new file mode 100644 index 0000000..c6d63d0 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,59 @@ +// Package cli builds the command tree and runs it. +package cli + +import ( + "errors" + "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" + "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(), age.Command(), mnemonic.Command()) + + return root +} + +// Main runs the tool and returns the status the process should exit +// with. An error ends the tool with status 1, except when it carries a +// status of its own, which "ssh to" uses to hand on the status ssh +// ended with. ssh has already said whatever it had to say in that +// case, so nothing more is printed. +func Main() int { + err := Root().Execute() + if err == nil { + 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/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..fa11a40 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,140 @@ +package cli_test + +import ( + "bytes" + "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" +) + +// 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" +) + +// 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 { + 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) +} + +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 { + 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/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/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/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 new file mode 100644 index 0000000..b15a08b --- /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(), install(), to()) + + 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/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/derive/derive.go b/internal/derive/derive.go new file mode 100644 index 0000000..bd95906 --- /dev/null +++ b/internal/derive/derive.go @@ -0,0 +1,94 @@ +// 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) +} + +// 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) { + err := CheckIndex(index) + if err != nil { + return nil, err + } + + master, err := Master(words) + if err != nil { + return nil, 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/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.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..2ab8cb6 --- /dev/null +++ b/internal/sshkey/sshkey_test.go @@ -0,0 +1,128 @@ +package sshkey_test + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" + + "git.eeqj.de/sneak/keyfunc/internal/derive" + "git.eeqj.de/sneak/keyfunc/internal/sshkey" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// 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 { + 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 := exampleKey(t) + + 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 := exampleKey(t) + + 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) +} + +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, exampleIndex) + 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 "$@"