0.1.0: deterministic SSH keys, age identities with encryption, and child mnemonics from one mnemonic #9
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
.gitea
|
||||
/keyfunc
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -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
|
||||
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal file
@@ -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
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -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
|
||||
|
||||
34
.golangci.yml
Normal file
34
.golangci.yml
Normal file
@@ -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
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -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"]
|
||||
15
Dockerfile.lint
Normal file
15
Dockerfile.lint
Normal file
@@ -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
|
||||
47
Makefile
Normal file
47
Makefile
Normal file
@@ -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
|
||||
421
REPO_POLICIES.md
Normal file
421
REPO_POLICIES.md
Normal file
@@ -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 `@<commit-sha>`). 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@<version> --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/<name>`. 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<tab>`, 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:
|
||||
@<test-command> || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
<test-command-with-v>; 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/<name>`. 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/<path>`.
|
||||
|
||||
- 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`
|
||||
27
go.mod
Normal file
27
go.mod
Normal file
@@ -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
|
||||
)
|
||||
136
go.sum
Normal file
136
go.sum
Normal file
@@ -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=
|
||||
203
internal/agekey/agekey.go
Normal file
203
internal/agekey/agekey.go
Normal file
@@ -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
|
||||
}
|
||||
169
internal/agekey/agekey_test.go
Normal file
169
internal/agekey/agekey_test.go
Normal file
@@ -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
|
||||
}
|
||||
63
internal/childmnemonic/childmnemonic.go
Normal file
63
internal/childmnemonic/childmnemonic.go
Normal file
@@ -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
|
||||
}
|
||||
117
internal/childmnemonic/childmnemonic_test.go
Normal file
117
internal/childmnemonic/childmnemonic_test.go
Normal file
@@ -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
|
||||
}
|
||||
263
internal/cli/age/age.go
Normal file
263
internal/cli/age/age.go
Normal file
@@ -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)
|
||||
}
|
||||
102
internal/cli/age_test.go
Normal file
102
internal/cli/age_test.go
Normal file
@@ -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
|
||||
}
|
||||
59
internal/cli/cli.go
Normal file
59
internal/cli/cli.go
Normal file
@@ -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
|
||||
}
|
||||
140
internal/cli/cli_test.go
Normal file
140
internal/cli/cli_test.go
Normal file
@@ -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
|
||||
}
|
||||
65
internal/cli/mnemonic/mnemonic.go
Normal file
65
internal/cli/mnemonic/mnemonic.go
Normal file
@@ -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)
|
||||
}
|
||||
43
internal/cli/options/options.go
Normal file
43
internal/cli/options/options.go
Normal file
@@ -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)
|
||||
}
|
||||
96
internal/cli/ssh/install.go
Normal file
96
internal/cli/ssh/install.go
Normal file
@@ -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
|
||||
}
|
||||
127
internal/cli/ssh/ssh.go
Normal file
127
internal/cli/ssh/ssh.go
Normal file
@@ -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/<index> 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
|
||||
}
|
||||
95
internal/cli/ssh/to.go
Normal file
95
internal/cli/ssh/to.go
Normal file
@@ -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 <host> [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)
|
||||
}
|
||||
255
internal/cli/ssh_test.go
Normal file
255
internal/cli/ssh_test.go
Normal file
@@ -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")
|
||||
}
|
||||
94
internal/derive/derive.go
Normal file
94
internal/derive/derive.go
Normal file
@@ -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
|
||||
}
|
||||
61
internal/derive/derive_test.go
Normal file
61
internal/derive/derive_test.go
Normal file
@@ -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)
|
||||
}
|
||||
117
internal/mnemonic/mnemonic.go
Normal file
117
internal/mnemonic/mnemonic.go
Normal file
@@ -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
|
||||
}
|
||||
87
internal/mnemonic/mnemonic_test.go
Normal file
87
internal/mnemonic/mnemonic_test.go
Normal file
@@ -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)
|
||||
}
|
||||
86
internal/sshkey/agent.go
Normal file
86
internal/sshkey/agent.go
Normal file
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
63
internal/sshkey/sshkey.go
Normal file
63
internal/sshkey/sshkey.go
Normal file
@@ -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
|
||||
}
|
||||
128
internal/sshkey/sshkey_test.go
Normal file
128
internal/sshkey/sshkey_test.go
Normal file
@@ -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
|
||||
}
|
||||
12
main.go
Normal file
12
main.go
Normal file
@@ -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())
|
||||
}
|
||||
69
script/bootstrap
Executable file
69
script/bootstrap
Executable file
@@ -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 <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||
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 "$@"
|
||||
14
script/check
Executable file
14
script/check
Executable file
@@ -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 "$@"
|
||||
17
script/cibuild
Executable file
17
script/cibuild
Executable file
@@ -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 "$@"
|
||||
14
script/docker
Executable file
14
script/docker
Executable file
@@ -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 "$@"
|
||||
12
script/fmt
Executable file
12
script/fmt
Executable file
@@ -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 "$@"
|
||||
17
script/fmt-check
Executable file
17
script/fmt-check
Executable file
@@ -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 "$@"
|
||||
16
script/install-precommit
Executable file
16
script/install-precommit
Executable file
@@ -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 "$@"
|
||||
18
script/lint
Executable file
18
script/lint
Executable file
@@ -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 "$@"
|
||||
19
script/precommit
Executable file
19
script/precommit
Executable file
@@ -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 "$@"
|
||||
12
script/projectname
Executable file
12
script/projectname
Executable file
@@ -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 "$@"
|
||||
13
script/setup
Executable file
13
script/setup
Executable file
@@ -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 "$@"
|
||||
13
script/test
Executable file
13
script/test
Executable file
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user